Kaynağa Gözat

test(tools): pin underscore-leading tool names to subscript access

Chinesezjc 2 ay önce
ebeveyn
işleme
a525d7d1e2

+ 16 - 7
packages/core/tools/src/py-types.ts

@@ -136,9 +136,13 @@ const MAX_CLASS_NAME_BASE = 120
  * nested parentheses`), so an array chain deeper than that would render an SDK
  * block that is not valid Python at all — the same failure the docstring
  * escaping in {@link docLines} exists to prevent. 180 leaves headroom for the
- * few brackets an annotation can add around the chain: `NotRequired[…]`, a
- * `Literal[…]` item, and the `def` parameter list an argument annotation sits
- * inside, for a worst case of 182.
+ * few brackets an annotation can add around the chain, all of which count
+ * toward the same limit: a `Literal[…]` item, plus exactly one of `NotRequired[…]`
+ * (a chain in a TypedDict field, whose class-body line has no other open
+ * bracket) or the `def` parameter list still open around a chain in a method's
+ * RETURN annotation — the two are mutually exclusive, so the worst case is 182.
+ * An argument annotation is always a bare TypedDict class name and opens
+ * nothing.
  *
  * A CPython grammar limit, not a deployment choice, so it is fixed rather than
  * configurable. The sibling `ts-types` renderer needs no counterpart: nothing
@@ -557,10 +561,15 @@ export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string {
       members.push(...doc)
       statements += 1
     } else {
-      // Not a legal attribute name — the model reaches it via ``tools[name]``.
-      // The stub lists it as a subscript comment (referencing the named
-      // TypedDicts too) so a reader sees what is accessible; runtime resolution
-      // goes through the proxy's __getitem__.
+      // Not reachable as ``tools.name`` — the model reaches it via
+      // ``tools[name]``. Exotic names and hard keywords are not legal
+      // attributes at all; an underscore-leading name (``_foo``) IS a legal
+      // attribute and is routed here anyway, so one rule covers every
+      // underscore form rather than singling out the dunders that would
+      // name-mangle or resolve on ``object`` ahead of the proxy hook (see
+      // {@link RESERVED}). The stub lists it as a subscript comment
+      // (referencing the named TypedDicts too) so a reader sees what is
+      // accessible; runtime resolution goes through the proxy's __getitem__.
       members.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`)
       const description = describe(schema)
       if (description !== undefined) members.push(`${pad(1)}#   ${description}`)

+ 23 - 1
packages/core/tools/tests/py-types.spec.ts

@@ -465,7 +465,8 @@ describe('renderToolsSdkPy', () => {
       output: { type: 'string' },
     }
     const text = renderToolsSdkPy([undescribedIdentifier, undescribedExotic])
-    // Identifier method appears without a docstring line above it.
+    // Identifier method appears without a docstring in its body — hence the
+    // `: ...` stub, which a documented method replaces with the docstring.
     expect(text).toContain('async def plain(self, args: dict[str, Any]) -> str: ...')
     expect(text).not.toContain('"""')
     // Subscript entry appears without the "#   ..." description follow-up.
@@ -660,6 +661,27 @@ describe('renderToolsSdkPy', () => {
     expect(text).not.toContain('__debug__')
   })
 
+  it('routes every underscore-leading tool name to subscript access', () => {
+    // `_foo` is a legal Python attribute, unlike an exotic name or a hard
+    // keyword, but the whole underscore family goes to `tools[name]` under one
+    // rule: `__meta__` resolves on `object` before the proxy's __getattr__ ever
+    // runs, and `__token` name-mangles at the CALL SITE inside the model's own
+    // class. `_foo` follows them so the rule needs no per-form exception.
+    const make = (name: string): ToolSdkSchema => ({
+      name,
+      description: 'Leading underscore.',
+      parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    })
+    const text = renderToolsSdkPy([make('_foo'), make('__meta__'), make('__token')])
+    for (const name of ['_foo', '__meta__', '__token']) {
+      expect(text).toContain(`# tools[${JSON.stringify(name)}](args: dict[str, Any]) -> str`)
+      expect(text).not.toContain(`async def ${name}(`)
+    }
+    // No method emitted at all, so the class body needs the explicit `pass`.
+    expect(text).toContain('    pass\n')
+  })
+
   it('escapes quotes and backslashes in descriptions so the docstring stays valid Python', () => {
     // A description ending in `"` or an odd backslash would otherwise merge
     // with (or escape) the closing triple quote — and this block is Code