From 8a9c051f80090895abbc64262bc90d95b42103c4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 11 Oct 2026 22:18:47 +0200 Subject: [PATCH] feat(codemode): show schema constraints in tool declarations Property comments now carry constraints the TypeScript type cannot express (integer, bounds, lengths, pattern, format, default, ...) as @ tags, read from nullable anyOf branches too. Types over the size limit are rendered again without tags before becoming unknown. closes #10707 --- packages/codemode/CHANGELOG.md | 1 + packages/codemode/README.md | 2 +- packages/codemode/src/declarations.ts | 101 +++++++++++++++-- packages/codemode/test/declarations.test.ts | 119 ++++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 1 + 5 files changed, 212 insertions(+), 12 deletions(-) diff --git a/packages/codemode/CHANGELOG.md b/packages/codemode/CHANGELOG.md index 82215ae75..d0ed018c2 100644 --- a/packages/codemode/CHANGELOG.md +++ b/packages/codemode/CHANGELOG.md @@ -11,6 +11,7 @@ ### Changed +- `schemaToType()` and the declarations built on it show property constraints the TypeScript type cannot express as tags on the property's comment line, such as `// @integer @minimum 0 @maximum 30000 @default 0`. Covered keywords: `integer`, numeric bounds, `multipleOf`, string length, `pattern`, `format`, array length, `uniqueItems`, non-null `default`, and `deprecated`; constraints of nullable `anyOf`/`oneOf` fields are read from the non-null branch, and draft-04 boolean `exclusiveMinimum`/`exclusiveMaximum` are shown as exclusive bounds. Property descriptions now also split on U+2028/U+2029. A type over `maxChars` is rendered again without tags before it becomes `unknown` ([#10707](https://github.com/earendil-works/pi/issues/10707)) - The VM now polls for interrupts while it drains promise jobs, so a script such as `while (true) await null` is stopped at the same points as a synchronous loop ### Fixed diff --git a/packages/codemode/README.md b/packages/codemode/README.md index fedcbdf85..aba861236 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -243,7 +243,7 @@ renderDeclarations({ tools: sandbox.tools, globals: sandbox.globals }); // }; ``` -Schemas only shape the declarations; values are not validated against them. Local references (`#/$defs/...`, `#/definitions/...`) are expanded; recursive and remote references render as `unknown`. +Property descriptions become `//` comments. Constraints the type cannot express follow as tags, such as `// @integer @minimum 1 @default 20`; they are dropped first when a type exceeds the size limit. Schemas only shape the declarations; values are not validated against them. Local references (`#/$defs/...`, `#/definitions/...`) are expanded; recursive and remote references render as `unknown`. ## Using with pi-agent-core diff --git a/packages/codemode/src/declarations.ts b/packages/codemode/src/declarations.ts index c7b57e05b..8661e179b 100644 --- a/packages/codemode/src/declarations.ts +++ b/packages/codemode/src/declarations.ts @@ -10,6 +10,27 @@ const INDENT = " "; export const DEFAULT_INPUT_SCHEMA_MAX_CHARS = 16_000; /** Local `$ref` expansions per rendered schema, so shared definitions cannot blow up the output. */ const MAX_REF_EXPANSIONS = 32; +/** JSON Schema keywords with a number or string value shown as `@keyword value` property tags. */ +const TAG_KEYWORDS = [ + "minimum", + "exclusiveMinimum", + "maximum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "format", + "minItems", + "maxItems", +]; +/** Tag keywords whose values all hold at once, so only the largest (lower) or smallest (upper) matters. */ +const LOWER_BOUNDS = new Set(["minimum", "exclusiveMinimum", "minLength", "minItems"]); +const UPPER_BOUNDS = new Set(["maximum", "exclusiveMaximum", "maxLength", "maxItems"]); +/** Longest property tag; longer ones (large defaults, long patterns) are left out. */ +const MAX_TAG_CHARS = 80; +/** Characters that end a `//` comment. */ +const LINE_TERMINATORS = /[\r\n\u2028\u2029]/g; /** * TypeScript types for MCP results, from the MCP `CallToolResult` schema, so `CallToolResult` @@ -220,14 +241,18 @@ function union(types: string[]): string { /** * Convert a JSON Schema to a TypeScript type expression: objects on one line (`{ a: string; b?: number; }`) with properties sorted by name, - * or one property per line with `//` comments when a property has a description; `Array` for - * arrays. Local references (`#/$defs/...`, `#/definitions/...`) resolve against `schema`; - * recursive and remote references render as `unknown`. A result longer than `maxChars` renders as - * `unknown`. + * or one property per line with `//` comments when a property has a description or constraints the + * type cannot express (`// @integer @minimum 1 @default 20`); `Array` for arrays. Local references + * (`#/$defs/...`, `#/definitions/...`) resolve against `schema`; recursive and remote references + * render as `unknown`. A result longer than `maxChars` is rendered again without the constraint tags, + * and renders as `unknown` if it is still too long. */ export function schemaToType(schema: CodemodeJsonSchema, options: { maxChars?: number } = {}): string { - const type = toType(schema, { root: schema, resolving: new Set(), expansions: 0 }); - return options.maxChars !== undefined && type.length > options.maxChars ? "unknown" : type; + const render = (tags: boolean) => toType(schema, { root: schema, resolving: new Set(), expansions: 0, tags }); + const type = render(true); + if (options.maxChars === undefined || type.length <= options.maxChars) return type; + const untagged = render(false); + return untagged.length <= options.maxChars ? untagged : "unknown"; } interface SchemaContext { @@ -235,6 +260,8 @@ interface SchemaContext { /** References being expanded on the current path, to stop at recursive types. */ resolving: Set; expansions: number; + /** Whether property comments include constraint tags. */ + tags: boolean; } function resolveRef(ref: string, root: CodemodeJsonSchema): CodemodeJsonSchema | undefined { @@ -324,6 +351,52 @@ function descriptionOf(property: unknown): string { return isObject(property) && typeof property.description === "string" ? property.description.trim() : ""; } +/** + * Constraints of a property that its TypeScript type cannot express, as JSDoc-style tags: + * `@integer @minimum 1 @maximum 100 @default 20`. Empty when there are none. + */ +function constraintTags(property: unknown): string { + if (!isObject(property)) return ""; + // Optional fields such as Pydantic's `int | None` keep their constraints in the non-null branch. + // The branch's constraints apply together with the property's, so both are read. + const variants = Array.isArray(property.anyOf) ? property.anyOf : property.oneOf; + const nonNull = Array.isArray(variants) ? variants.filter((v) => !isObject(v) || v.type !== "null") : []; + // Draft-04 and OpenAPI 3.0 make `minimum`/`maximum` exclusive with a boolean `exclusiveMinimum`/`exclusiveMaximum`. + const schemas = (nonNull.length === 1 && isObject(nonNull[0]) ? [property, nonNull[0]] : [property]).map( + (schema): Record => ({ + ...schema, + ...(schema.exclusiveMinimum === true ? { minimum: undefined, exclusiveMinimum: schema.minimum } : {}), + ...(schema.exclusiveMaximum === true ? { maximum: undefined, exclusiveMaximum: schema.maximum } : {}), + }), + ); + + const tags: string[] = []; + const isInteger = (type: unknown) => type === "integer" || (Array.isArray(type) && type.includes("integer")); + if (schemas.some((schema) => isInteger(schema.type))) tags.push("@integer"); + for (const keyword of TAG_KEYWORDS) { + const values = schemas.map((schema) => schema[keyword]); + if (LOWER_BOUNDS.has(keyword) || UPPER_BOUNDS.has(keyword)) { + const numbers = values.filter((value) => typeof value === "number"); + if (numbers.length === 0) continue; + tags.push(`@${keyword} ${LOWER_BOUNDS.has(keyword) ? Math.max(...numbers) : Math.min(...numbers)}`); + continue; + } + for (const value of new Set(values)) { + if (typeof value === "number" || typeof value === "string") tags.push(`@${keyword} ${value}`); + } + } + if (schemas.some((schema) => schema.uniqueItems === true)) tags.push("@uniqueItems"); + // Annotations, unlike constraints, come from the property itself. + if (property.default !== undefined && property.default !== null) { + tags.push(`@default ${JSON.stringify(property.default)}`); + } + if (property.deprecated === true) tags.push("@deprecated"); + return tags + .filter((tag) => tag.length <= MAX_TAG_CHARS) + .map((tag) => tag.replace(LINE_TERMINATORS, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`)) + .join(" "); +} + function objectType(schema: Record, context: SchemaContext): string { const properties = isObject(schema.properties) ? schema.properties : {}; const required = new Set(Array.isArray(schema.required) ? schema.required : []); @@ -340,13 +413,19 @@ function objectType(schema: Record, context: SchemaContext): st members.push("[key: string]: unknown;"); } if (members.length === 0) return "{}"; - if (!names.some((name) => descriptionOf(properties[name]))) return `{ ${members.join(" ")} }`; + const comments = names.map((name) => { + const lines = descriptionOf(properties[name]) + .split(LINE_TERMINATORS) + .map((line) => line.trim()) + .filter(Boolean); + const tags = context.tags ? constraintTags(properties[name]) : ""; + return tags ? [...lines, tags] : lines; + }); + if (!comments.some((comment) => comment.length > 0)) return `{ ${members.join(" ")} }`; const lines = ["{"]; - names.forEach((name, index) => { - for (const line of descriptionOf(properties[name]).split(/\r?\n/)) { - if (line.trim()) lines.push(`${INDENT}// ${line.trim()}`); - } + comments.forEach((comment, index) => { + for (const line of comment) lines.push(`${INDENT}// ${line}`); lines.push(`${INDENT}${members[index].replaceAll("\n", `\n${INDENT}`)}`); }); for (const member of members.slice(names.length)) lines.push(`${INDENT}${member}`); diff --git a/packages/codemode/test/declarations.test.ts b/packages/codemode/test/declarations.test.ts index 043f69cff..35bc2cb25 100644 --- a/packages/codemode/test/declarations.test.ts +++ b/packages/codemode/test/declarations.test.ts @@ -83,6 +83,125 @@ describe("schemaToType", () => { ).toBe("{\n // Outer\n outer?: {\n // Inner\n inner?: string;\n };\n}"); }); + // https://github.com/earendil-works/pi/issues/10707 + it("puts constraints the type cannot express on property comment lines", () => { + expect( + schemaToType({ + type: "object", + properties: { + timeoutMs: { + type: "integer", + minimum: 0, + maximum: 30000, + default: 0, + description: "Wait in milliseconds.", + }, + id: { type: "string", pattern: "^[a-z]+$", maxLength: 8 }, + tags: { type: "array", items: { type: "string" }, maxItems: 5, uniqueItems: true, deprecated: true }, + name: { type: "string" }, + }, + required: ["id"], + }), + ).toBe( + [ + "{", + " // @maxLength 8 @pattern ^[a-z]+$", + " id: string;", + " name?: string;", + " // @maxItems 5 @uniqueItems @deprecated", + " tags?: Array;", + " // Wait in milliseconds.", + " // @integer @minimum 0 @maximum 30000 @default 0", + " timeoutMs?: number;", + "}", + ].join("\n"), + ); + }); + + it("reads constraints of optional fields from the non-null branch", () => { + // Pydantic's `limit: int | None = Field(None, ge=1, le=100)`. + expect( + schemaToType({ + type: "object", + properties: { + limit: { + anyOf: [{ type: "integer", minimum: 1, maximum: 100 }, { type: "null" }], + default: null, + title: "Limit", + }, + }, + }), + ).toBe("{\n // @integer @minimum 1 @maximum 100\n limit?: number | null;\n}"); + // Constraints of the property and the branch both apply. + expect( + schemaToType({ + type: "object", + properties: { + n: { + anyOf: [{ type: "integer", minimum: 10, maximum: 50, format: "int32" }, { type: "null" }], + minimum: 0, + maximum: 20, + format: "port", + }, + }, + }), + ).toBe("{\n // @integer @minimum 10 @maximum 20 @format port @format int32\n n?: number | null;\n}"); + }); + + it("reads draft-04 boolean exclusive bounds", () => { + expect( + schemaToType({ + type: "object", + properties: { + n: { type: "integer", minimum: 0, exclusiveMinimum: true, maximum: 10, exclusiveMaximum: false }, + m: { anyOf: [{ type: "number", maximum: 5, exclusiveMaximum: true }, { type: "null" }], maximum: 8 }, + }, + }), + ).toBe( + [ + "{", + " // @maximum 8 @exclusiveMaximum 5", + " m?: number | null;", + " // @integer @exclusiveMinimum 0 @maximum 10", + " n?: number;", + "}", + ].join("\n"), + ); + }); + + it("leaves out long tags and escapes line terminators", () => { + expect( + schemaToType({ + type: "object", + properties: { config: { type: "object", default: { text: "x".repeat(100) } } }, + }), + ).toBe("{ config?: { [key: string]: unknown; }; }"); + expect( + schemaToType({ + type: "object", + properties: { + sep: { type: "string", pattern: "^[\r\n\u2028\u2029]$", default: "\u2028", description: "a\u2028b" }, + }, + }), + ).toBe( + [ + "{", + " // a", + " // b", + ' // @pattern ^[\\u000d\\u000a\\u2028\\u2029]$ @default "\\u2028"', + " sep?: string;", + "}", + ].join("\n"), + ); + }); + + it("drops constraint tags before rendering a type over the budget as unknown", () => { + const schema = { type: "object", properties: { n: { type: "integer", minimum: 1 } } }; + expect(schemaToType(schema)).toBe("{\n // @integer @minimum 1\n n?: number;\n}"); + expect(schemaToType(schema, { maxChars: 20 })).toBe("{ n?: number; }"); + expect(schemaToType(schema, { maxChars: 10 })).toBe("unknown"); + }); + it("resolves local references and stops at recursive ones", () => { const schema = { type: "object", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6d3cbda15..1f6407d23 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - Changed the `showHardwareCursor` setting to use only the terminal cursor instead of also drawing Pi's reverse-video cursor +- Changed codemode tool declarations to show input constraints such as integer, minimum/maximum, length, pattern, and default as `@` tags on property comments, so scripts see them without the tool repeating them in descriptions ([#10707](https://github.com/earendil-works/pi/issues/10707)) - Changed `serializeConversation()` (compaction, branch summaries, bug reports) to omit thinking blocks; replayed thinking is often not in the live context, so including it could make a summarization request larger than the context it summarizes and fail ### Fixed