mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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
This commit is contained in:
parent
78a3c10ba7
commit
8a9c051f80
5 changed files with 212 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>`
|
||||
|
|
@ -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<T>` 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<T>` 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<string>;
|
||||
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<string, unknown> => ({
|
||||
...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<string, unknown>, 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<string, unknown>, 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}`);
|
||||
|
|
|
|||
|
|
@ -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<string>;",
|
||||
" // 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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue