diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 90cae053a4..c879e04a72 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -29,9 +29,10 @@ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ * every tool and field without collisions. * Soft keywords (``match``, ``case``, ``type``, ``_`` — the language * reference's whole set) are deliberately ABSENT: each is special in exactly - * one syntactic position — a statement head, or a ``match`` pattern for ``_`` - * — so ``match: str`` as a field and ``async def match(...)`` as a method are - * both legal, and including + * one syntactic position — a statement head (``match``, ``type``), a ``match`` + * statement's clause head (``case``), or a pattern (``_``) — so ``match: str`` + * as a field and ``async def match(...)`` as a method are both legal, and + * including * them would needlessly degrade common search/regex tool fields to * ``dict[str, Any]``. Underscore-leading names are handled separately, not * here: a non-dunder ``__token`` name-mangles, a dunder present on @@ -262,13 +263,21 @@ function childClassName(base: string, segment: string): string { * by a JS parser back into the same double. * * `JSON.stringify` is also what keeps this path's output parseable, and it is - * the only thing that does: it escapes both code points CPython refuses in - * source — NUL among the C0 controls, and unpaired surrogates under ES2019 - * well-formed stringification, which the engines range guarantees. The + * the only thing that does. It covers both classes of hazard: the two code + * points CPython refuses anywhere in source — NUL among the C0 controls, and + * unpaired surrogates under ES2019 well-formed stringification, which the + * engines range guarantees — and the ones that break this line in particular, + * a bare `"` closing the literal early, a trailing odd backslash eating the + * closing quote, and a bare LF/CR ending it before its terminator. The * `description` path carries {@link UNPRINTABLE} and {@link LONE_SURROGATE} - * because nothing quotes it. DEL and the C1 controls do reach a `Literal[...]` - * raw — legal but invisible, byte-for-byte as in the TS flavor; escaping them - * is a both-flavors change. + * because nothing quotes it, and folds newlines in {@link describe}. + * + * That leans on a coincidence worth naming: every escape `JSON.stringify` can + * emit (`\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`) is also a Python + * escape denoting the same character, so the emitted `Literal[...]` both + * parses and decodes back to the value the schema declared. DEL and the C1 + * controls do reach it raw — legal but invisible, byte-for-byte as in the TS + * flavor; escaping them is a both-flavors change. */ function pyScalar(value: JsonSchemaScalar): string { if (value === true) return 'True' diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 933881fd50..1fa6e5064d 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -350,6 +350,21 @@ describe('mode-aware wire contribution', () => { expect(sdk?.text).toContain('top-level `await`') }) + it("assembles under a python runtime in mode 'both' as well, SDK and schema together", async () => { + // `both` reaches the same wireSchemas/requireCodeRuntime/SDK-section code + // as `code`, so this pins the mode-by-language matrix rather than a + // separate path — including that `schemas()` under `both` projects the + // Python flavor instead of hitting the flavor-table guard. + const { ctx, systemPrompt } = await setup({ mode: 'both', runtime: { language: 'python' } }) + registerEcho(ctx) + const assembly = await systemPrompt.assemble() + expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('class Tools(Protocol):') + const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME) + expect(runCodeSchema?.description).toContain('Execute a Python program') + // `both` keeps the native tools alongside run_code; `code` does not. + expect(assembly.tools.map(tool => tool.name)).toContain('echo') + }) + it('emits a TypeScript-flavored run_code schema under a typescript runtime', async () => { const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'typescript' } }) registerEcho(ctx) diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 8adf364171..35d5449bfe 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -52,12 +52,18 @@ describe('jsonSchemaToPy', () => { }) it('leans on JSON.stringify to keep a Literal parseable', () => { - // The two code points CPython refuses in source reach this path as well, - // and nothing here escapes them itself — `JSON.stringify` does, NUL as a - // C0 control and a lone surrogate under ES2019 well-formed stringification. - // Python decodes both escapes back to the value the schema declared. + // Nothing here escapes anything itself; `JSON.stringify` carries both + // classes of hazard. The two code points CPython refuses anywhere in + // source: NUL, and a lone surrogate under ES2019 well-formed + // stringification. expect(jsonSchemaToPy({ type: 'string', const: 'a\u0000b' })).toBe(String.raw`Literal["a\u0000b"]`) expect(jsonSchemaToPy({ type: 'string', enum: ['a\ud800b'] })).toBe(String.raw`Literal["a\ud800b"]`) + // And the ones that break this line in particular: a bare quote closing + // the literal early, a trailing backslash eating the closing quote, a bare + // newline ending it before its terminator. Every escape it emits is also a + // Python escape for the same character, so the value round-trips. + expect(jsonSchemaToPy({ type: 'string', const: 'say "hi"\n' })).toBe(String.raw`Literal["say \"hi\"\n"]`) + expect(jsonSchemaToPy({ type: 'string', const: 'ends\\' })).toBe(String.raw`Literal["ends\\"]`) }) it('emits exact digits for a beyond-safe-range integer literal', () => {