tangled
alpha
login
or
join now
davidgasquez.com
/
dotfiles
1
fork
atom
🔧 Where my dotfiles lives in harmony and peace, most of the time
1
fork
atom
overview
issues
pulls
pipelines
✨ Add pi output schema extension
davidgasquez.com
1 month ago
5bfdbfc2
6b2ffd56
+399
1 changed file
expand all
collapse all
unified
split
agents
pi
extensions
output-schema.ts
+399
agents/pi/extensions/output-schema.ts
reviewed
···
1
1
+
import { readFileSync } from "node:fs";
2
2
+
import { resolve } from "node:path";
3
3
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
4
+
import { Type } from "@sinclair/typebox";
5
5
+
6
6
+
const FLAG_NAME = "output-schema";
7
7
+
const TOOL_NAME = "submit_output";
8
8
+
const MAX_REPAIR_ATTEMPTS = 2;
9
9
+
10
10
+
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
11
11
+
12
12
+
type JsonSchema = {
13
13
+
type?: string;
14
14
+
properties?: Record<string, JsonSchema>;
15
15
+
required?: string[];
16
16
+
additionalProperties?: boolean;
17
17
+
items?: JsonSchema;
18
18
+
enum?: Json[];
19
19
+
description?: string;
20
20
+
[key: string]: unknown;
21
21
+
};
22
22
+
23
23
+
type SubmitOutputParams = Record<string, Json>;
24
24
+
25
25
+
type AgentEndEvent = {
26
26
+
messages?: Array<{
27
27
+
role?: string;
28
28
+
content?: Array<{ type?: string; text?: string }>;
29
29
+
}>;
30
30
+
};
31
31
+
32
32
+
function isRecord(value: unknown): value is Record<string, unknown> {
33
33
+
return typeof value === "object" && value !== null && !Array.isArray(value);
34
34
+
}
35
35
+
36
36
+
function asJsonSchema(value: unknown): JsonSchema {
37
37
+
if (!isRecord(value)) {
38
38
+
throw new Error("output schema must be a JSON object");
39
39
+
}
40
40
+
return value as JsonSchema;
41
41
+
}
42
42
+
43
43
+
function assertSupportedSchema(
44
44
+
schema: JsonSchema,
45
45
+
path = "$",
46
46
+
isRoot = true,
47
47
+
): void {
48
48
+
for (const key of [
49
49
+
"$ref",
50
50
+
"oneOf",
51
51
+
"anyOf",
52
52
+
"allOf",
53
53
+
"not",
54
54
+
"if",
55
55
+
"then",
56
56
+
"else",
57
57
+
"patternProperties",
58
58
+
]) {
59
59
+
if (key in schema) {
60
60
+
throw new Error(
61
61
+
`${FLAG_NAME}: unsupported schema feature at ${path}: ${key}`,
62
62
+
);
63
63
+
}
64
64
+
}
65
65
+
66
66
+
if (schema.enum !== undefined) {
67
67
+
if (!Array.isArray(schema.enum) || schema.enum.length === 0) {
68
68
+
throw new Error(
69
69
+
`${FLAG_NAME}: enum at ${path} must be a non-empty array`,
70
70
+
);
71
71
+
}
72
72
+
return;
73
73
+
}
74
74
+
75
75
+
const type = schema.type;
76
76
+
if (typeof type !== "string") {
77
77
+
throw new Error(
78
78
+
`${FLAG_NAME}: schema at ${path} must declare a string type`,
79
79
+
);
80
80
+
}
81
81
+
82
82
+
if (isRoot && type !== "object") {
83
83
+
throw new Error(`${FLAG_NAME}: root schema must be an object`);
84
84
+
}
85
85
+
86
86
+
if (
87
87
+
![
88
88
+
"object",
89
89
+
"array",
90
90
+
"string",
91
91
+
"number",
92
92
+
"integer",
93
93
+
"boolean",
94
94
+
"null",
95
95
+
].includes(type)
96
96
+
) {
97
97
+
throw new Error(`${FLAG_NAME}: unsupported type at ${path}: ${type}`);
98
98
+
}
99
99
+
100
100
+
if (type === "object") {
101
101
+
const properties = schema.properties;
102
102
+
if (!isRecord(properties)) {
103
103
+
throw new Error(
104
104
+
`${FLAG_NAME}: object schema at ${path} must define properties`,
105
105
+
);
106
106
+
}
107
107
+
108
108
+
if (schema.required !== undefined && !Array.isArray(schema.required)) {
109
109
+
throw new Error(
110
110
+
`${FLAG_NAME}: required at ${path} must be an array of strings`,
111
111
+
);
112
112
+
}
113
113
+
114
114
+
for (const [key, child] of Object.entries(properties)) {
115
115
+
assertSupportedSchema(asJsonSchema(child), `${path}.${key}`, false);
116
116
+
}
117
117
+
return;
118
118
+
}
119
119
+
120
120
+
if (type === "array") {
121
121
+
if (schema.items === undefined) {
122
122
+
throw new Error(
123
123
+
`${FLAG_NAME}: array schema at ${path} must define items`,
124
124
+
);
125
125
+
}
126
126
+
assertSupportedSchema(asJsonSchema(schema.items), `${path}[]`, false);
127
127
+
}
128
128
+
}
129
129
+
130
130
+
function isJson(value: unknown): value is Json {
131
131
+
if (value === null) return true;
132
132
+
if (typeof value === "string" || typeof value === "boolean") return true;
133
133
+
if (typeof value === "number") return Number.isFinite(value);
134
134
+
if (Array.isArray(value)) return value.every(isJson);
135
135
+
if (!isRecord(value)) return false;
136
136
+
return Object.values(value).every(isJson);
137
137
+
}
138
138
+
139
139
+
function describeValue(value: unknown): string {
140
140
+
if (value === null) return "null";
141
141
+
if (Array.isArray(value)) return "array";
142
142
+
return typeof value;
143
143
+
}
144
144
+
145
145
+
function jsonEquals(left: Json, right: Json): boolean {
146
146
+
return JSON.stringify(left) === JSON.stringify(right);
147
147
+
}
148
148
+
149
149
+
function validateValue(
150
150
+
schema: JsonSchema,
151
151
+
value: unknown,
152
152
+
path = "$",
153
153
+
errors: string[] = [],
154
154
+
): string[] {
155
155
+
if (schema.enum !== undefined) {
156
156
+
if (
157
157
+
!isJson(value) ||
158
158
+
!schema.enum.some((item) => jsonEquals(item, value))
159
159
+
) {
160
160
+
errors.push(`${path} must be one of the enum values`);
161
161
+
}
162
162
+
return errors;
163
163
+
}
164
164
+
165
165
+
switch (schema.type) {
166
166
+
case "object": {
167
167
+
if (!isRecord(value) || Array.isArray(value)) {
168
168
+
errors.push(`${path} must be an object`);
169
169
+
return errors;
170
170
+
}
171
171
+
172
172
+
const properties = isRecord(schema.properties) ? schema.properties : {};
173
173
+
const required = Array.isArray(schema.required) ? schema.required : [];
174
174
+
175
175
+
for (const key of required) {
176
176
+
if (!(key in value)) {
177
177
+
errors.push(`${path}.${key} is required`);
178
178
+
}
179
179
+
}
180
180
+
181
181
+
if (schema.additionalProperties === false) {
182
182
+
for (const key of Object.keys(value)) {
183
183
+
if (!(key in properties)) {
184
184
+
errors.push(`${path}.${key} is not allowed`);
185
185
+
}
186
186
+
}
187
187
+
}
188
188
+
189
189
+
for (const [key, childSchema] of Object.entries(properties)) {
190
190
+
if (!(key in value)) continue;
191
191
+
validateValue(
192
192
+
asJsonSchema(childSchema),
193
193
+
value[key],
194
194
+
`${path}.${key}`,
195
195
+
errors,
196
196
+
);
197
197
+
}
198
198
+
return errors;
199
199
+
}
200
200
+
case "array": {
201
201
+
if (!Array.isArray(value)) {
202
202
+
errors.push(`${path} must be an array`);
203
203
+
return errors;
204
204
+
}
205
205
+
206
206
+
const itemSchema = asJsonSchema(schema.items);
207
207
+
for (let index = 0; index < value.length; index += 1) {
208
208
+
validateValue(itemSchema, value[index], `${path}[${index}]`, errors);
209
209
+
}
210
210
+
return errors;
211
211
+
}
212
212
+
case "string": {
213
213
+
if (typeof value !== "string") errors.push(`${path} must be a string`);
214
214
+
return errors;
215
215
+
}
216
216
+
case "number": {
217
217
+
if (typeof value !== "number" || !Number.isFinite(value)) {
218
218
+
errors.push(`${path} must be a finite number`);
219
219
+
}
220
220
+
return errors;
221
221
+
}
222
222
+
case "integer": {
223
223
+
if (typeof value !== "number" || !Number.isInteger(value)) {
224
224
+
errors.push(`${path} must be an integer`);
225
225
+
}
226
226
+
return errors;
227
227
+
}
228
228
+
case "boolean": {
229
229
+
if (typeof value !== "boolean") errors.push(`${path} must be a boolean`);
230
230
+
return errors;
231
231
+
}
232
232
+
case "null": {
233
233
+
if (value !== null) errors.push(`${path} must be null`);
234
234
+
return errors;
235
235
+
}
236
236
+
default: {
237
237
+
errors.push(
238
238
+
`${path} has unsupported schema type ${describeValue(schema.type)}`,
239
239
+
);
240
240
+
return errors;
241
241
+
}
242
242
+
}
243
243
+
}
244
244
+
245
245
+
function getFinalAssistantText(event: AgentEndEvent): string | undefined {
246
246
+
const messages = event.messages ?? [];
247
247
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
248
248
+
const message = messages[index];
249
249
+
if (message.role !== "assistant") continue;
250
250
+
const text = (message.content ?? [])
251
251
+
.filter((part) => part.type === "text" && typeof part.text === "string")
252
252
+
.map((part) => part.text ?? "")
253
253
+
.join("\n")
254
254
+
.trim();
255
255
+
return text;
256
256
+
}
257
257
+
return undefined;
258
258
+
}
259
259
+
260
260
+
function buildSystemPrompt(basePrompt: string, schemaPath: string): string {
261
261
+
return `${basePrompt}\n\n[Structured output contract]\nThe user started pi with --${FLAG_NAME} ${schemaPath}.\nYou must finish by calling ${TOOL_NAME} exactly once with arguments that match the provided JSON schema.\n- ${TOOL_NAME} must be your final tool call.\n- Do not end with prose, markdown, or explanations.\n- After the tool call, your final assistant text must be exactly the same JSON object and nothing else.\n- If the tool rejects your arguments, fix them and call ${TOOL_NAME} again.`;
262
262
+
}
263
263
+
264
264
+
export default function outputSchemaExtension(pi: ExtensionAPI): void {
265
265
+
let enabled = false;
266
266
+
let schemaPath: string | undefined;
267
267
+
let schema: JsonSchema | undefined;
268
268
+
let acceptedJson: string | undefined;
269
269
+
let repairAttempts = 0;
270
270
+
let repairing = false;
271
271
+
let toolRegistered = false;
272
272
+
273
273
+
function isActive(): boolean {
274
274
+
return enabled && schema !== undefined && schemaPath !== undefined;
275
275
+
}
276
276
+
277
277
+
function ensureToolRegistered(): void {
278
278
+
if (toolRegistered || !schema) return;
279
279
+
280
280
+
pi.registerTool({
281
281
+
name: TOOL_NAME,
282
282
+
label: "Submit Output",
283
283
+
description:
284
284
+
"Submit the final JSON output. Use this exactly once as the final tool call.",
285
285
+
promptSnippet:
286
286
+
"Submit the final response as JSON matching the requested schema.",
287
287
+
promptGuidelines: [
288
288
+
`Call ${TOOL_NAME} exactly once when the task is complete.`,
289
289
+
"Pass arguments that match the active output schema exactly.",
290
290
+
"After the tool call, emit exactly the same JSON and nothing else.",
291
291
+
],
292
292
+
parameters: Type.Unsafe<Record<string, Json>>(schema as never),
293
293
+
async execute(_toolCallId: string, params: SubmitOutputParams) {
294
294
+
if (!isActive() || !schema) {
295
295
+
throw new Error(
296
296
+
`${TOOL_NAME} is only available when --${FLAG_NAME} is set`,
297
297
+
);
298
298
+
}
299
299
+
300
300
+
const errors = validateValue(schema, params);
301
301
+
if (errors.length > 0) {
302
302
+
throw new Error(
303
303
+
`output does not match schema:\n- ${errors.join("\n- ")}`,
304
304
+
);
305
305
+
}
306
306
+
307
307
+
acceptedJson = JSON.stringify(params);
308
308
+
repairing = false;
309
309
+
return {
310
310
+
content: [{ type: "text", text: acceptedJson }],
311
311
+
details: { output: params },
312
312
+
};
313
313
+
},
314
314
+
});
315
315
+
316
316
+
toolRegistered = true;
317
317
+
}
318
318
+
319
319
+
function ensureToolActive(): void {
320
320
+
if (!toolRegistered) return;
321
321
+
const activeTools = new Set(pi.getActiveTools());
322
322
+
if (activeTools.has(TOOL_NAME)) return;
323
323
+
activeTools.add(TOOL_NAME);
324
324
+
pi.setActiveTools([...activeTools]);
325
325
+
}
326
326
+
327
327
+
function resetState(): void {
328
328
+
enabled = false;
329
329
+
schemaPath = undefined;
330
330
+
schema = undefined;
331
331
+
acceptedJson = undefined;
332
332
+
repairAttempts = 0;
333
333
+
repairing = false;
334
334
+
}
335
335
+
336
336
+
pi.registerFlag(FLAG_NAME, {
337
337
+
description:
338
338
+
"Path to a JSON Schema file that the final response must match",
339
339
+
type: "string",
340
340
+
});
341
341
+
342
342
+
pi.on("session_start", async (_event, ctx) => {
343
343
+
resetState();
344
344
+
345
345
+
const flagValue = pi.getFlag(FLAG_NAME);
346
346
+
if (typeof flagValue !== "string" || flagValue.trim().length === 0) {
347
347
+
return;
348
348
+
}
349
349
+
350
350
+
schemaPath = resolve(ctx.cwd, flagValue);
351
351
+
const raw = readFileSync(schemaPath, "utf8");
352
352
+
schema = asJsonSchema(JSON.parse(raw));
353
353
+
assertSupportedSchema(schema);
354
354
+
enabled = true;
355
355
+
ensureToolRegistered();
356
356
+
ensureToolActive();
357
357
+
});
358
358
+
359
359
+
pi.on("before_agent_start", async (event) => {
360
360
+
if (!isActive() || !schemaPath) return;
361
361
+
ensureToolActive();
362
362
+
363
363
+
if (!repairing) {
364
364
+
acceptedJson = undefined;
365
365
+
repairAttempts = 0;
366
366
+
}
367
367
+
368
368
+
return {
369
369
+
systemPrompt: buildSystemPrompt(event.systemPrompt, schemaPath),
370
370
+
};
371
371
+
});
372
372
+
373
373
+
pi.on("agent_end", async (event: AgentEndEvent, ctx) => {
374
374
+
if (!isActive() || !schemaPath) return;
375
375
+
if (repairAttempts >= MAX_REPAIR_ATTEMPTS) return;
376
376
+
377
377
+
if (!acceptedJson) {
378
378
+
repairAttempts += 1;
379
379
+
repairing = true;
380
380
+
const followUp = `You stopped without calling ${TOOL_NAME}. Call ${TOOL_NAME} now with JSON that matches ${schemaPath}. After the tool call, output exactly the same JSON and nothing else.`;
381
381
+
if (ctx.isIdle()) pi.sendUserMessage(followUp);
382
382
+
else pi.sendUserMessage(followUp, { deliverAs: "followUp" });
383
383
+
return;
384
384
+
}
385
385
+
386
386
+
const finalAssistantText = getFinalAssistantText(event);
387
387
+
if (finalAssistantText === acceptedJson) {
388
388
+
repairAttempts = 0;
389
389
+
repairing = false;
390
390
+
return;
391
391
+
}
392
392
+
393
393
+
repairAttempts += 1;
394
394
+
repairing = true;
395
395
+
const correction = `Your final response must be exactly this JSON and nothing else:\n${acceptedJson}`;
396
396
+
if (ctx.isIdle()) pi.sendUserMessage(correction);
397
397
+
else pi.sendUserMessage(correction, { deliverAs: "followUp" });
398
398
+
});
399
399
+
}