From cabeaed1eb85862ea561b59a899acc3d61501f47 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 16:42:32 +0800 Subject: [PATCH] refactor(tools): type the py-types render frame and tighten the note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the bot's review of the trusted-after-validation revert: - renderType's Frame now carries JsonSchemaNode (the root schema is asserted before any frame is built), dropping the `as Record` casts, the `node.oneOf as unknown[]` cast, and the runtime `required` filter — the same typed-frame shape as the sibling ts-types renderer, so the "symmetric with ts-types" claim holds structurally, not just behaviorally. - The language-dispatch note broadens the trusted-input argument to cover all real sources (first-party defineTool/raw registration and wire-derived plain JSON), and the zh side uses full-width punctuation per translation-rules.md. py-types.ts stays at 100% per-file coverage. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/py-types.ts | 44 ++++++++++--------- 4 files changed, 28 insertions(+), 24 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 fb6dcecc95..1bab3fef1a 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: 9f001b8fad8ca954d9b0c3cdca0e7be4d3b9ce61 -2026-07-31-code-mode-language-dispatch.zh.md: 525bb8a97e4e6d9e00334d5425acd1491a9b3fc7 +2026-07-31-code-mode-language-dispatch.md: 2fdda0f886630b27037d715ede21300f8ae9177f +2026-07-31-code-mode-language-dispatch.zh.md: 7bf82a856b7578462c7bb1fed40d8108b82cda57 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 9f001b8fad..2fdda0f886 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 @@ -25,7 +25,7 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri `py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. -`renderType` validates the whole schema once (`assertSupportedJsonSchema`) and then trusts it, wrapping the walk in one `try/catch` that degrades to `Any` — the same trusted-after-validation stance the sibling `ts-types` renderer takes at this typed same-process seam ([Trust TypeScript at typed same-process seams](../../../../AGENTS.md)). It deliberately carries NO defenses against a schema whose accessors mutate between reads (post-validation cycles, TOCTOU on `const`/`enum`, self-referential functions): the input is a first-party `defineTool` object literal that already passed validation, so such inputs are unreachable, and adding per-shape guards here would break symmetry with `ts-types` (which has none) for values the static interface forbids. `jsonSchemaToPy(schema: unknown)` accepts `unknown` and returns `Any` on a malformed schema — the Python counterpart of the TS flavor's `unknown` — but its contract is "degrade an unsupported schema", not "survive an adversarial mutating one". +`renderType` validates the whole schema once (`assertSupportedJsonSchema`) and then trusts it, wrapping the walk in one `try/catch` that degrades to `Any` — the same trusted-after-validation stance the sibling `ts-types` renderer takes at this typed same-process seam ([Trust TypeScript at typed same-process seams](../../../../AGENTS.md)). It deliberately carries NO defenses against a schema whose accessors mutate between reads (post-validation cycles, TOCTOU on `const`/`enum`, self-referential functions): the input is a first-party registration (a `defineTool` literal or a raw registration) or a wire-derived plain JSON schema — the former is trusted per AGENTS.md, the latter is a `JSON.parse` product that physically cannot carry accessors, and `renderType` re-validates the whole tree on every call regardless — so such inputs are unreachable, and adding per-shape guards here would break symmetry with `ts-types` (which has none) for values the static interface forbids. `jsonSchemaToPy(schema: unknown)` accepts `unknown` and returns `Any` on a malformed schema — the Python counterpart of the TS flavor's `unknown` — but its contract is "degrade an unsupported schema", not "survive an adversarial mutating one". ## Alternatives considered 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 525bb8a97e..7bf82a856b 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 @@ -25,7 +25,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd `py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。 -`renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的"校验后信任"姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对"访问器在多次读取间变值"的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数):输入是已通过校验的第一方 `defineTool` 对象字面量,这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的契约是"降级不支持的 schema",而非"扛住对抗性的可变 schema"。 +`renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的「校验后信任」姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对「访问器在多次读取间变值」的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数):输入是第一方注册(`defineTool` 字面量或 raw 注册)或从 wire 桥接而来的纯 JSON——前者按 AGENTS.md 受信任,后者是 `JSON.parse` 产物、物理上不可能携带访问器,且每次调用 `renderType` 都会整树重新校验——这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的契约是「降级不支持的 schema」,而非「扛住对抗性的可变 schema」。 ## Alternatives considered diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index ef5a122dc4..22e459bc3e 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -14,7 +14,7 @@ */ import { assertSupportedJsonSchema } from './json-schema.ts' -import type { JsonSchemaScalar } from './json-schema.ts' +import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts' import type { ToolSdkSchema } from './ts-types.ts' /** Property names that are valid bare Python identifiers; anything else is subscripted. */ @@ -183,14 +183,14 @@ function pyScalar(value: JsonSchemaScalar): string { * the stub is advisory prompt text, only required to parse — and keeping the * exact value communicates the constraint to the model. */ -function renderConstrainedScalar(node: Record, broad: string, state: RenderState): string { - if (Object.hasOwn(node, 'const')) { +function renderConstrainedScalar(node: JsonSchemaNode, broad: string, state: RenderState): string { + if (node.const !== undefined) { state.typing.add('Literal') - return `Literal[${pyScalar(node.const as JsonSchemaScalar)}]` + return `Literal[${pyScalar(node.const)}]` } - if (Object.hasOwn(node, 'enum')) { + if (node.enum !== undefined) { state.typing.add('Literal') - return `Literal[${(node.enum as JsonSchemaScalar[]).map(pyScalar).join(', ')}]` + return `Literal[${node.enum.map(pyScalar).join(', ')}]` } return broad } @@ -208,18 +208,22 @@ function renderConstrainedScalar(node: Record, broad: string, s */ function renderType(schema: unknown, className: string, state: RenderState): string { interface Frame { - schema: unknown + // A validated JSON-schema node past the root `assertSupportedJsonSchema` + // (the root frame's schema is asserted before any frame is built), so the + // walk reads its fields without casts — the same typed-frame shape as the + // sibling ts-types renderer. + schema: JsonSchemaNode className: string phase: 'start' | 'children' kind?: 'oneOf' | 'array' | 'typeddict' - node?: Record - children: { schema: unknown; className: string }[] + node?: JsonSchemaNode + children: { schema: JsonSchemaNode; className: string }[] childIndex: number childTypes: string[] - entries: [string, unknown][] + entries: [string, JsonSchemaNode][] allocated?: string } - const newFrame = (schema: unknown, className: string): Frame => + const newFrame = (schema: JsonSchemaNode, className: string): Frame => ({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [] }) try { // Validate the WHOLE tree once, then trust it — the same contract the @@ -272,7 +276,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str const name = frame.allocated /* v8 ignore next -- typeddict frames always set node and allocated at start. */ if (node === undefined || name === undefined) throw new Error('missing typeddict frame state') - const required = new Set(Array.isArray(node.required) ? node.required.filter((n): n is string => typeof n === 'string') : []) + const required = new Set(node.required) const lines = [`class ${name}(TypedDict):`] for (let index = 0; index < frame.entries.length; index++) { const entry = frame.entries[index] @@ -281,8 +285,8 @@ function renderType(schema: unknown, className: string, state: RenderState): str if (entry === undefined || fieldType === undefined) throw new Error('missing typeddict field type') const [field, fieldSchema] = entry // The parent node passed assertSupportedJsonSchema, so every property - // value is a validated schema node (an object). - const description = describe(fieldSchema as object) + // value is a validated schema node. + const description = describe(fieldSchema) if (description !== undefined) lines.push(`${pad(1)}# ${description}`) if (required.has(field)) { lines.push(`${pad(1)}${field}: ${fieldType}`) @@ -307,13 +311,13 @@ function renderType(schema: unknown, className: string, state: RenderState): str } frame.phase = 'children' - const node = frame.schema as Record - if (Object.hasOwn(node, 'oneOf')) { + const node = frame.schema + if (node.oneOf !== undefined) { frame.kind = 'oneOf' - frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) + frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) continue } - if (!Object.hasOwn(node, 'type')) { + if (node.type === undefined) { state.typing.add('Any') finish('Any') continue @@ -325,7 +329,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str case 'boolean': finish(renderConstrainedScalar(node, 'bool', state)); break case 'null': finish('None'); break case 'array': { - if (!Object.hasOwn(node, 'items')) { + if (node.items === undefined) { state.typing.add('Any') finish('list[Any]') break @@ -341,7 +345,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str // shape. The openness of the resulting empty object is decided below, // so a closed empty object still declares an empty TypedDict rather // than a permissive `dict[str, Any]`. - const entries = Object.entries((node.properties ?? {}) as Record) + const entries = Object.entries(node.properties ?? {}) // An empty `className` marks the context-free `jsonSchemaToPy` entry: // there is no naming context to declare into, so degrade. A field // name that is not a legal Python attribute is inexpressible as a