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:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md
|
||||
2026-07-31-code-mode-language-dispatch.md: 2d9649b922157992c86e3421aeeac23a84a4edb4
|
||||
2026-07-31-code-mode-language-dispatch.zh.md: 61af77eb43d61061683f3ab6bf0d3c71587792a9
|
||||
2026-07-31-code-mode-language-dispatch.md: cbcc8eb54ce78b922e584d050bb9d6a73439a08c
|
||||
2026-07-31-code-mode-language-dispatch.zh.md: 5502daf926a62fa2b6981457be8f2b5583f477b8
|
||||
@@ -40,3 +40,5 @@ The standard that cap serves is grammatical validity, and the boundary is delibe
|
||||
Adding a backend language is two table entries — an `SDK_RENDERERS` entry and a `RUN_CODE_FLAVORS` entry — plus the renderer function the former points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend.
|
||||
|
||||
The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript.
|
||||
|
||||
Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists.
|
||||
@@ -40,3 +40,5 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd
|
||||
新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。
|
||||
|
||||
代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。
|
||||
|
||||
Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"""')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user