From 1b4cb031f0ff195f155c41ad719bfef5715c890b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 5 Aug 2026 17:45:38 +0800 Subject: [PATCH] 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. --- ...07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 2 ++ ...026-07-31-code-mode-language-dispatch.zh.md | 2 ++ packages/core/tools/src/index.ts | 8 ++++++++ packages/core/tools/src/py-types.ts | 18 ++++++++++++++---- packages/core/tools/tests/py-types.spec.ts | 18 ++++++++++++++++++ 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index c34c5166ae..5cafc77562 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 2d9649b922..cbcc8eb54c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 61af77eb43..5502daf926 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -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`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5c523af878..820390228e 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -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') diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 3f3707b4eb..d85660ee86 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -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. diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index cc7b55c4c1..158c043909 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -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"""') }) })