fix(tools): name the two bound SDK names and escape NEL

The static-stub sentence over-generalized: `tools` and `ToolCallError`
ARE bound at run time, and a model reading "everything below is a stub"
could stop catching `ToolCallError`. State the boundary and pin both
halves in the fixed-instruction assertions.

UNPRINTABLE missed U+0085: it is Cc but not ECMAScript whitespace, so
it survived the collapse and reached the docstring raw and invisible.
Add it and scope the docstring to Cc, since the `\xNN` escape cannot
address the Cf formatting characters that pass through by design.

Record the backend PR's two runtime contracts -- inject only `tools`
and `ToolCallError`, and bind the assembly-time language to the
request -- in the Agent Note and at requireCodeRuntime.
This commit is contained in:
Chinesezjc
2026-08-05 17:45:38 +08:00
parent bc94431c34
commit 1b4cb031f0
6 changed files with 46 additions and 6 deletions

View File

@@ -836,6 +836,14 @@ export class ToolRegistry extends Service {
* behind it — hostage to a code runtime existing even under `mode:
* 'native'` (the loop's optional-backend idiom, same as
* `sessionPersistence`).
*
* Assembly and `run_code` execution read separately, so the language is not
* bound to a request. Harmless while one published backend exists — both
* reads return the same flavor — but a reload that swapped in a second
* language between them would hand a program written against one SDK to the
* other. Binding it belongs to the PR that publishes that backend, which is
* also the first point it can be tested; recorded in the
* [language-dispatch note](../../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md).
*/
private requireCodeRuntime(): CodeRuntime {
const runtime = this.ctx.get('codeRuntime')

View File

@@ -70,15 +70,25 @@ interface RenderState {
}
/**
* Control characters that survive the whitespace collapse in {@link describe}
* and have no printable form. CPython rejects source containing a NUL outright
* The `Cc` code points that survive the whitespace collapse in {@link describe}
* and have no printable form: the C0 controls, DEL, and NEL. U+0009 to U+000D
* are absent because ECMAScript `\s` already collapsed them; U+0085 is `Cc` but
* NOT in `\s` (TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus LF/CR/LS/PS), so it survives and
* is escaped here. CPython rejects source containing a NUL outright
* (`SyntaxError: source code string cannot contain null bytes`), whether it
* sits in a docstring or in a comment, so one such byte anywhere in a schema
* description would make the whole generated SDK unparseable — the model's only
* declaration of the tools. The rest are legal but invisible; escaping them
* with the same rule keeps the emitted text readable and the treatment uniform.
*
* The set stops at `Cc` because the escape is `\xNN`, which addresses exactly
* U+0000 to U+00FF. The invisible `Cf` formatting characters (U+00AD soft
* hyphen, U+200B ZWSP, U+200E/U+200F bidi marks, U+2060 word joiner) pass
* through by design: covering them would need a second `\uNNNN` escape form,
* and they are legal in both consumers — only LF and CR terminate a Python
* string literal or a `#` comment.
*/
const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f]/g
const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f\u0085]/g
/**
* The collapsed one-line `description` of a schema node (byte-stable across
@@ -519,7 +529,7 @@ export function jsonSchemaToPy(schema: unknown): string {
/** The fixed model-facing usage contract rendered above the declarations. */
const SDK_INSTRUCTIONS = `## Writing code for run_code
Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). Everything declared below is a STATIC STUB describing shapes: the \`TypedDict\` classes are NOT bound 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:
Pass \`run_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 shapes — 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:
- 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.
- 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.

View File

@@ -106,6 +106,12 @@ describe('renderToolsSdkPy', () => {
expect(text).toContain('# tools["class"](args: dict[str, Any]) -> str')
// Fixed instruction lines the model relies on.
expect(text).toContain('top-level `await`')
// The binding boundary: `tools`/`ToolCallError` are bound, the TypedDicts
// are not. Both halves are pinned — dropping either one turns a correct
// contract into a wrong one (a model that reads only "STATIC STUB" would
// stop catching `ToolCallError`).
expect(text).toContain('exactly two of the names declared below are bound: `tools` and `ToolCallError`')
expect(text).toContain('never `FooArgs(field=1)`, which raises `NameError`')
expect(text).toContain('ToolCallError')
expect(text).toContain('class ToolCallError(Exception):')
expect(text).toContain('MAY overlap under `asyncio.gather`')
@@ -732,5 +738,17 @@ describe('renderToolsSdkPy', () => {
const others = renderToolsSdkPy([make('bell\u0007esc\u001bdel\u007f')])
expect(others).toContain(String.raw`bell\x07esc\x1bdel\x7f`)
expect(renderToolsSdkPy([make('tab\tnewline\ncr\r')])).toContain('"""tab newline cr"""')
// NEL is the one `Cc` code point the collapse does NOT fold: ECMAScript
// whitespace is TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus LF/CR/LS/PS, and U+0085 is
// in none of them, so without the escape it would reach the docstring raw
// and be invisible there. NBSP, which IS whitespace, folds instead.
const nel = renderToolsSdkPy([make('a\u0085b')])
expect(nel).not.toContain('\u0085')
expect(nel).toContain(String.raw`# a\x85b`)
expect(renderToolsSdkPy([make('nb\u00a0sp')])).toContain('"""nb sp"""')
// `Cf` formatting characters pass through by design: `\xNN` cannot address
// them, and they terminate neither a Python string literal nor a `#`
// comment, so the block stays parseable with the code point intact.
expect(renderToolsSdkPy([make('zero\u200bwidth')])).toContain('"""zero\u200bwidth"""')
})
})