fix(tools): correct the trim-order claim and check the Literal escape dependency
trim and escape commute for every input, so the new whitespace test does
not pin their order: UNPRINTABLE and LONE_SURROGATE are disjoint from the
set trim() strips, and both escapes emit plain non-whitespace ASCII,
leaving the leading and trailing whitespace runs byte-identical. State that instead of the false causal clause.
pyScalar's Literal path escapes nothing itself -- JSON.stringify is what
keeps it parseable, covering NUL and, under ES2019 well-formed
stringification, unpaired surrogates. Record the dependency and turn it
into a checked invariant. Pin the docstring emission site for a lone
surrogate too, mirroring the NUL case.
Two docstring corrections: describe's caller enumeration omitted the
synthetic { description } wrapper docLines builds, and "special in
statement position" does not describe `_`, which is special in a match
pattern. Both keep the conclusion they support.
Note which of the two table guards fires depends on the entry point.
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: cbcc8eb54ce78b922e584d050bb9d6a73439a08c
|
||||
2026-07-31-code-mode-language-dispatch.zh.md: 5502daf926a62fa2b6981457be8f2b5583f477b8
|
||||
2026-07-31-code-mode-language-dispatch.md: d891ef171344d729ae93f98f6662608f432e5b78
|
||||
2026-07-31-code-mode-language-dispatch.zh.md: fd1c00f754b0e6c21cac659ac303482ec60e156a
|
||||
|
||||
@@ -37,7 +37,7 @@ The standard that cap serves is grammatical validity, and the boundary is delibe
|
||||
|
||||
## Consequences
|
||||
|
||||
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.
|
||||
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. Which of the two failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. 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.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd
|
||||
|
||||
## Consequences
|
||||
|
||||
新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。
|
||||
新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。对两张表都缺席的语言,报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 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。
|
||||
|
||||
|
||||
@@ -27,9 +27,11 @@ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
* class-syntax `TypedDict` field. Such a tool renders under subscript access
|
||||
* and such an object degrades to ``dict[str, Any]`` — the model still reaches
|
||||
* every tool and field without collisions.
|
||||
* Soft keywords (``match``, ``case``, ``type``, ``_``) are deliberately
|
||||
* ABSENT: they are only special in statement position, so ``match: str`` as a
|
||||
* field and ``async def match(...)`` as a method are both legal, and including
|
||||
* 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
|
||||
* 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
|
||||
@@ -113,8 +115,9 @@ const LONE_SURROGATE = /[\ud800-\udfff]/gu
|
||||
/**
|
||||
* The collapsed one-line `description` of a schema node (byte-stable across
|
||||
* formatting churn), or `undefined` when the node carries none. Every caller
|
||||
* passes an object (validated property nodes, or the ToolSdkSchema itself),
|
||||
* so only the description field needs guarding. A description that collapses
|
||||
* passes an object — a validated property node, the `ToolSdkSchema` itself, or
|
||||
* the `{ description }` wrapper {@link docLines} synthesizes — so only the
|
||||
* description field needs guarding. A description that collapses
|
||||
* to nothing (empty, or whitespace only) is `undefined` too: it documents the
|
||||
* node no better than an absent one, and emitting it would leave an empty
|
||||
* `"""` docstring or a bare `# ` line in the SDK. Only ECMAScript whitespace
|
||||
@@ -257,6 +260,15 @@ function childClassName(base: string, segment: string): string {
|
||||
* representable as a JavaScript number, so the SDK would document a value no
|
||||
* program can pass. The TS flavor needs no counterpart: its literal is re-read
|
||||
* 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
|
||||
* `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.
|
||||
*/
|
||||
function pyScalar(value: JsonSchemaScalar): string {
|
||||
if (value === true) return 'True'
|
||||
|
||||
@@ -51,6 +51,15 @@ describe('jsonSchemaToPy', () => {
|
||||
expect(jsonSchemaToPy({ type: 'string', enum: [] })).toBe('Any')
|
||||
})
|
||||
|
||||
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.
|
||||
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"]`)
|
||||
})
|
||||
|
||||
it('emits exact digits for a beyond-safe-range integer literal', () => {
|
||||
// Python integers are arbitrary-precision, so the emitted digits ARE the
|
||||
// value the model programs against. `String(2 ** 60)` prints the rounded
|
||||
@@ -750,7 +759,9 @@ describe('renderToolsSdkPy', () => {
|
||||
// so the block stays parseable with the code point intact.
|
||||
expect(renderToolsSdkPy([described('zero\u200bwidth')])).toContain('"""zero\u200bwidth"""')
|
||||
// Whitespace around a surviving control character is not an absent
|
||||
// description: the escape runs before the trim, so what is left is visible.
|
||||
// description. The escape's output is non-whitespace ASCII and the escaped
|
||||
// sets are disjoint from what `trim()` strips, so the two operations touch
|
||||
// different characters and their order is unobservable.
|
||||
expect(renderToolsSdkPy([described(' \u0085 ')])).toContain(String.raw`# \x85`)
|
||||
})
|
||||
|
||||
@@ -764,6 +775,7 @@ describe('renderToolsSdkPy', () => {
|
||||
const high = renderToolsSdkPy([described('a\ud800b')])
|
||||
expect(high).not.toContain('\ud800')
|
||||
expect(high).toContain(String.raw`# a\ud800b`)
|
||||
expect(high).toContain(String.raw`"""a\\ud800b"""`)
|
||||
// A lone LOW surrogate is just as unencodable, and `\xNN` reaches neither.
|
||||
expect(renderToolsSdkPy([described('a\udfffb')])).toContain(String.raw`# a\udfffb`)
|
||||
// A well-formed pair is ONE astral code point, not two surrogates — the
|
||||
|
||||
Reference in New Issue
Block a user