fix(tools): emit Python SDK members in one lexicographic stream
The Python renderer partitioned identifier methods ahead of subscript
comments, so a tool set like {a-tool, z} emitted z first — contradicting
the documented lexicographic contract and the TypeScript flavor, which
quotes exotic keys in place. Interleave both kinds in one ordered stream
and track emitted statements for the pass fallback.
Also correct four stale serialization claims in the base Code Mode note
that the live-parallel scheduler superseded.
This commit is contained in:
@@ -471,30 +471,34 @@ The available tools:`
|
||||
export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string {
|
||||
const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
||||
const state: RenderState = { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set(['Protocol']) }
|
||||
const inlineMembers: string[] = []
|
||||
const subscriptMembers: string[] = []
|
||||
// ONE ordered member stream, matching the documented lexicographic contract
|
||||
// and the TypeScript flavor (which quotes exotic keys in place rather than
|
||||
// partitioning them out). Interleaving is free here: a comment line between
|
||||
// two `async def` lines is not a statement, so it changes nothing about how
|
||||
// the class body parses.
|
||||
const members: string[] = []
|
||||
let statements = 0
|
||||
for (const schema of sorted) {
|
||||
const argType = renderType(schema.parameters, `${camelCase(schema.name)}Args`, state)
|
||||
const outputType = renderType(schema.output, `${camelCase(schema.name)}Output`, state)
|
||||
if (IDENTIFIER.test(schema.name) && !RESERVED.has(schema.name) && !schema.name.startsWith('_')) {
|
||||
inlineMembers.push(...docLines(schema.description, 1))
|
||||
inlineMembers.push(`${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`)
|
||||
members.push(...docLines(schema.description, 1))
|
||||
members.push(`${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`)
|
||||
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__.
|
||||
subscriptMembers.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`)
|
||||
members.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`)
|
||||
const description = describe(schema)
|
||||
if (description !== undefined) subscriptMembers.push(`${pad(1)}# ${description}`)
|
||||
if (description !== undefined) members.push(`${pad(1)}# ${description}`)
|
||||
}
|
||||
}
|
||||
// Subscript entries are COMMENTS, not statements: a class body of only
|
||||
// comments fails to parse, so `pass` is required whenever no inline method
|
||||
// exists — including the subscript-only tool set.
|
||||
const bodyLines = inlineMembers.length > 0
|
||||
? [...inlineMembers, ...subscriptMembers]
|
||||
: [`${pad(1)}pass`, ...subscriptMembers]
|
||||
// comments fails to parse, so `pass` is required whenever no method was
|
||||
// emitted — including the subscript-only tool set.
|
||||
const bodyLines = statements > 0 ? members : [`${pad(1)}pass`, ...members]
|
||||
const body = bodyLines.join('\n')
|
||||
const imports = TYPING_ORDER.filter(symbol => state.typing.has(symbol))
|
||||
const classBlock = state.classes.length > 0 ? `${state.classes.join('\n\n')}\n\n` : ''
|
||||
|
||||
@@ -390,11 +390,24 @@ describe('renderToolsSdkPy', () => {
|
||||
// Descriptions on subscript names ride as a comment beside their entry.
|
||||
expect(text).toContain('# tools["my-mcp.tool"]')
|
||||
expect(text).toContain('# Exotic name.')
|
||||
// Lexicographic: `bash` before `my-mcp.tool` (identifier methods first,
|
||||
// then subscript comments — the emitter partitions).
|
||||
// Lexicographic: `bash` before `my-mcp.tool`.
|
||||
expect(text.indexOf('async def bash')).toBeLessThan(text.indexOf('# tools["my-mcp.tool"]'))
|
||||
})
|
||||
|
||||
it('orders subscript entries against methods by name, not by member kind', () => {
|
||||
// `a-tool` sorts before `z`, so the subscript comment must precede the
|
||||
// method: one ordered stream, not methods-then-comments.
|
||||
const noArgs = parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>
|
||||
const text = renderToolsSdkPy([
|
||||
{ name: 'z', description: 'Last by name.', parameters: noArgs, output: { type: 'string' } },
|
||||
{ name: 'a-tool', description: 'First by name.', parameters: noArgs, output: { type: 'string' } },
|
||||
])
|
||||
expect(text.indexOf('# tools["a-tool"]')).toBeLessThan(text.indexOf('async def z'))
|
||||
// The interleaved comment does not disturb the class body: `z` still parses
|
||||
// as the statement that keeps `pass` out.
|
||||
expect(text).not.toContain(`${' '.repeat(4)}pass`)
|
||||
})
|
||||
|
||||
it('is deterministic: byte-identical output regardless of input order or duplication', () => {
|
||||
expect(renderToolsSdkPy([bash, exotic])).toBe(renderToolsSdkPy([exotic, bash]))
|
||||
expect(renderToolsSdkPy([bash, bash])).toBe(renderToolsSdkPy([bash, bash]))
|
||||
|
||||
Reference in New Issue
Block a user