@zhushanwen/pi-structured-output 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/index.ts +68 -29
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-structured-output",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Structured output tool for Pi — enforces JSON Schema via tool call mechanism with Ajv validation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"pi": {
|
|
8
8
|
"extensions": [
|
|
9
|
-
"./
|
|
9
|
+
"./index.ts"
|
|
10
10
|
]
|
|
11
11
|
},
|
|
12
12
|
"keywords": [
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,15 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Design: FR-1 to FR-5 from spec, FR-4 dual-layer enforcement.
|
|
9
9
|
* Reference: Claude Code's SyntheticOutputTool (Ajv + Stop hook).
|
|
10
|
+
*
|
|
11
|
+
* Key design decisions (borrowed from Claude Code):
|
|
12
|
+
* - Schema is injected into BOTH system prompt AND tool description, so LLM
|
|
13
|
+
* knows the exact output structure regardless of which signal it reads.
|
|
14
|
+
* - Enforcement checks "last call succeeded" (not "was called"), so validation
|
|
15
|
+
* failures trigger retries rather than being silently skipped.
|
|
16
|
+
* - Retry cap prevents infinite enforcement loops on persistent schema mismatch.
|
|
17
|
+
* - WeakMap caches Ajv compile results for repeated calls with the same schema
|
|
18
|
+
* object reference (mirrors Claude Code's toolCache pattern).
|
|
10
19
|
*/
|
|
11
20
|
|
|
12
21
|
import Ajv, { type ValidateFunction } from "ajv";
|
|
@@ -18,14 +27,27 @@ type PiAPI = any;
|
|
|
18
27
|
|
|
19
28
|
const ENV_KEY = "STRUCTURED_OUTPUT_SCHEMA";
|
|
20
29
|
const TOOL_NAME = "structured-output";
|
|
21
|
-
|
|
22
|
-
const SYSTEM_PROMPT =
|
|
23
|
-
"你必须在完成分析后调用 structured-output tool 来返回结构化结果。" +
|
|
24
|
-
"不要在文本回复中输出 JSON,直接调用 structured-output tool。" +
|
|
25
|
-
"这是你返回最终结果的唯一方式。";
|
|
30
|
+
const MAX_RETRIES = parseInt(process.env.MAX_STRUCTURED_OUTPUT_RETRIES || "5", 10);
|
|
26
31
|
|
|
27
32
|
const ENFORCEMENT_MESSAGE = "你必须调用 structured-output tool 来返回结果。";
|
|
28
33
|
|
|
34
|
+
// ── Ajv WeakMap cache ─────────────────────────────────────────
|
|
35
|
+
// Workflow scripts may call agent({schema}) 30-80 times per run.
|
|
36
|
+
// Without caching, each call does new Ajv() + validateSchema() + compile().
|
|
37
|
+
// WeakMap keyed by schema object reference brings this to near-zero overhead.
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
const ajvCache = new WeakMap<object, ValidateFunction>();
|
|
40
|
+
|
|
41
|
+
function getOrCompileValidator(schema: Record<string, unknown>): ValidateFunction {
|
|
42
|
+
const cached = ajvCache.get(schema);
|
|
43
|
+
if (cached) return cached;
|
|
44
|
+
|
|
45
|
+
const ajv = new Ajv({ strict: false });
|
|
46
|
+
const validate = ajv.compile(schema);
|
|
47
|
+
ajvCache.set(schema, validate);
|
|
48
|
+
return validate;
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
export default function structuredOutputExtension(pi: PiAPI): void {
|
|
30
52
|
const schemaStr = process.env[ENV_KEY];
|
|
31
53
|
if (!schemaStr) return;
|
|
@@ -39,22 +61,37 @@ export default function structuredOutputExtension(pi: PiAPI): void {
|
|
|
39
61
|
return;
|
|
40
62
|
}
|
|
41
63
|
|
|
42
|
-
// Compile with Ajv
|
|
43
|
-
const ajv = new Ajv({ strict: false });
|
|
64
|
+
// Compile with Ajv (cached)
|
|
44
65
|
let validate: ValidateFunction;
|
|
45
66
|
try {
|
|
46
|
-
validate =
|
|
67
|
+
validate = getOrCompileValidator(schema);
|
|
47
68
|
} catch (e) {
|
|
48
69
|
console.error(`[${TOOL_NAME}] Invalid JSON Schema:`, (e as Error).message);
|
|
49
70
|
return;
|
|
50
71
|
}
|
|
51
72
|
|
|
52
|
-
//
|
|
73
|
+
// Build prompts with schema embedded
|
|
74
|
+
const schemaJsonStr = JSON.stringify(schema, null, 2);
|
|
75
|
+
const systemPrompt =
|
|
76
|
+
"你必须在完成分析后调用 structured-output tool 来返回结构化结果。" +
|
|
77
|
+
"不要在文本回复中输出 JSON,直接调用 structured-output tool。" +
|
|
78
|
+
"这是你返回最终结果的唯一方式。\n\n" +
|
|
79
|
+
"输出必须严格符合以下 JSON Schema:\n" + schemaJsonStr;
|
|
80
|
+
|
|
81
|
+
const toolDescription =
|
|
82
|
+
"Return structured output conforming to the JSON Schema. " +
|
|
83
|
+
"You MUST call this tool exactly once to return your final result.\n\n" +
|
|
84
|
+
"The output must conform to this JSON Schema:\n" + schemaJsonStr;
|
|
85
|
+
|
|
86
|
+
// Track structured-output call state per turn
|
|
87
|
+
let turnCallCount = 0;
|
|
88
|
+
let lastCallSucceeded = false;
|
|
89
|
+
|
|
90
|
+
// Register tool
|
|
53
91
|
pi.registerTool({
|
|
54
92
|
name: TOOL_NAME,
|
|
55
93
|
label: "Structured Output",
|
|
56
|
-
description:
|
|
57
|
-
"Return structured output conforming to the JSON Schema. You MUST call this tool to return your final result.",
|
|
94
|
+
description: toolDescription,
|
|
58
95
|
promptSnippet: "Call structured-output with your final structured answer",
|
|
59
96
|
promptGuidelines: [
|
|
60
97
|
"You MUST call structured-output as your final action.",
|
|
@@ -69,44 +106,46 @@ export default function structuredOutputExtension(pi: PiAPI): void {
|
|
|
69
106
|
.join("; ");
|
|
70
107
|
throw new Error(`Schema validation failed: ${errors}`);
|
|
71
108
|
}
|
|
109
|
+
// Mark success for enforcement check
|
|
110
|
+
lastCallSucceeded = true;
|
|
72
111
|
return {
|
|
73
112
|
content: [
|
|
74
113
|
{ type: "text" as const, text: "Structured output recorded successfully." },
|
|
75
114
|
],
|
|
76
115
|
details: params,
|
|
77
|
-
terminate: true,
|
|
78
116
|
};
|
|
79
117
|
},
|
|
80
118
|
});
|
|
81
119
|
|
|
82
|
-
// System prompt injection
|
|
120
|
+
// System prompt injection with embedded schema
|
|
83
121
|
pi.on("before_agent_start", async (_event: unknown, ctx: { addSystemInstruction: (s: string) => void }) => {
|
|
84
|
-
ctx.addSystemInstruction(
|
|
122
|
+
ctx.addSystemInstruction(systemPrompt);
|
|
85
123
|
});
|
|
86
124
|
|
|
87
|
-
//
|
|
88
|
-
let hasStructuredOutputCall = false;
|
|
89
|
-
|
|
125
|
+
// Track calls — count for retry cap, success for enforcement
|
|
90
126
|
pi.on("tool_execution_start", async (event: { toolName: string }) => {
|
|
91
127
|
if (event.toolName === TOOL_NAME) {
|
|
92
|
-
|
|
128
|
+
turnCallCount++;
|
|
93
129
|
}
|
|
94
130
|
});
|
|
95
131
|
|
|
96
132
|
pi.on("turn_end", async () => {
|
|
97
|
-
if (
|
|
98
|
-
|
|
133
|
+
if (lastCallSucceeded) {
|
|
134
|
+
// Reset for next potential turn (shouldn't happen, but defensive)
|
|
135
|
+
lastCallSucceeded = false;
|
|
136
|
+
turnCallCount = 0;
|
|
137
|
+
return;
|
|
99
138
|
}
|
|
100
|
-
});
|
|
101
139
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
140
|
+
// Retry cap: stop enforcement after MAX_RETRIES attempts
|
|
141
|
+
if (turnCallCount >= MAX_RETRIES) {
|
|
142
|
+
console.error(
|
|
143
|
+
`[${TOOL_NAME}] Max retries (${MAX_RETRIES}) reached without valid output. Giving up enforcement.`,
|
|
144
|
+
);
|
|
145
|
+
turnCallCount = 0;
|
|
146
|
+
return;
|
|
109
147
|
}
|
|
110
|
-
|
|
148
|
+
|
|
149
|
+
pi.sendUserMessage(ENFORCEMENT_MESSAGE);
|
|
111
150
|
});
|
|
112
151
|
}
|