@zhushanwen/pi-structured-output 0.3.0 → 0.3.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 +1 -1
- package/src/index.ts +158 -23
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Structured Output Extension —
|
|
2
|
+
* Structured Output Extension — 条件激活的 schema 校验工具 + hook
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 激活模式:
|
|
5
|
+
* - 日常 pi(interactive / 普通 print):不设置 PI_WORKFLOW_SCHEMA,扩展不注册工具
|
|
6
|
+
* - workflow 子进程:agent-pool 设置 PI_WORKFLOW_SCHEMA=<json>,扩展注册工具 + hook
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Hook 机制(仅 workflow 模式):
|
|
9
|
+
* turn_end 时检查模型是否调用了 structured-output 工具。
|
|
10
|
+
* 如果没调 → 通过 pi.sendUserMessage() 注入 steering message 强制调用。
|
|
11
|
+
* 最多重试 2 次,防止无限循环。
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import Ajv, { type ValidateFunction } from "ajv";
|
|
@@ -16,10 +19,10 @@ import { Type } from "@sinclair/typebox";
|
|
|
16
19
|
type PiAPI = any;
|
|
17
20
|
|
|
18
21
|
const TOOL_NAME = "structured-output";
|
|
22
|
+
const ENV_SCHEMA = "PI_WORKFLOW_SCHEMA";
|
|
23
|
+
const MAX_HOOK_RETRIES = 2;
|
|
19
24
|
|
|
20
25
|
// ── Ajv WeakMap cache ─────────────────────────────────────────
|
|
21
|
-
// Repeated calls with the same schema object reference are cached.
|
|
22
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
26
|
const ajvCache = new WeakMap<object, ValidateFunction>();
|
|
24
27
|
|
|
25
28
|
function getOrCompileValidator(schema: Record<string, unknown>): ValidateFunction {
|
|
@@ -32,41 +35,56 @@ function getOrCompileValidator(schema: Record<string, unknown>): ValidateFunctio
|
|
|
32
35
|
return validate;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
// ── Tool definition (shared between modes) ─────────────────────
|
|
39
|
+
|
|
40
|
+
function createToolDefinition() {
|
|
41
|
+
return {
|
|
37
42
|
name: TOOL_NAME,
|
|
38
43
|
label: "Structured Output",
|
|
39
44
|
description:
|
|
40
45
|
"Return structured output validated against a JSON Schema. "
|
|
41
|
-
+ "Call this tool
|
|
42
|
-
+ "Pass
|
|
43
|
-
+ "
|
|
46
|
+
+ "Call this tool to produce validated JSON data. "
|
|
47
|
+
+ "Pass `schema` (a JSON Schema draft-07 object) and `data` (the value to validate).\n\n"
|
|
48
|
+
+ "✅ Correct: schema={type:'object',properties:{name:{type:'string'},age:{type:'number'}},required:['name']}, data={name:'Alice',age:30}\n"
|
|
49
|
+
+ "✅ Correct: schema={type:'array',items:{type:'string'}}, data=['a','b','c']\n"
|
|
50
|
+
+ "✅ Correct: schema={type:'string',enum:['low','medium','high']}, data='medium'\n\n"
|
|
51
|
+
+ "❌ Wrong: putting the answer in text instead of calling this tool\n"
|
|
52
|
+
+ "❌ Wrong: data not matching schema (e.g. schema requires number but data is string)\n"
|
|
53
|
+
+ "❌ Wrong: schema={type:'object'} with data='hello' (string ≠ object)",
|
|
44
54
|
promptSnippet:
|
|
45
55
|
"Use structured-output to return validated JSON data. "
|
|
46
|
-
+ "Pass schema (JSON Schema) and data (your output). "
|
|
47
|
-
+ "
|
|
56
|
+
+ "Pass schema (JSON Schema draft-07) and data (your output). "
|
|
57
|
+
+ "Example: {schema:{type:'object',properties:{score:{type:'number'}},required:['score']}, data:{score:8}}",
|
|
48
58
|
promptGuidelines: [
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"Do not output JSON in text —
|
|
59
|
+
"schema must be a valid JSON Schema (draft-07). data must conform to it.",
|
|
60
|
+
"Both primitive types (string, number, boolean) and complex types (object, array) are valid schema roots.",
|
|
61
|
+
"Do not output JSON in text — call this tool instead.",
|
|
52
62
|
],
|
|
53
63
|
parameters: Type.Object({
|
|
54
|
-
schema: Type.
|
|
55
|
-
description: "JSON Schema object
|
|
64
|
+
schema: Type.Unknown({
|
|
65
|
+
description: "JSON Schema draft-07 object. Example: {type:'object',properties:{name:{type:'string'}},required:['name']}",
|
|
56
66
|
}),
|
|
57
|
-
data: Type.
|
|
58
|
-
description: "The value to validate against
|
|
67
|
+
data: Type.Unknown({
|
|
68
|
+
description: "The value to validate against schema. Example: {name:'Alice'}",
|
|
59
69
|
}),
|
|
60
70
|
}),
|
|
61
71
|
async execute(
|
|
62
72
|
_toolCallId: string,
|
|
63
73
|
params: { schema: Record<string, unknown>; data: unknown },
|
|
64
74
|
) {
|
|
65
|
-
|
|
75
|
+
// Normalize: some models pass schema/data as JSON strings instead of objects
|
|
76
|
+
let schema = params.schema;
|
|
77
|
+
let data = params.data;
|
|
78
|
+
if (typeof schema === "string") {
|
|
79
|
+
try { schema = JSON.parse(schema); } catch { /* malformed JSON — keep raw string, Ajv will reject */ }
|
|
80
|
+
}
|
|
81
|
+
if (typeof data === "string") {
|
|
82
|
+
try { data = JSON.parse(data); } catch { /* malformed JSON — keep raw string, Ajv will reject */ }
|
|
83
|
+
}
|
|
66
84
|
|
|
67
85
|
let validate: ValidateFunction;
|
|
68
86
|
try {
|
|
69
|
-
validate = getOrCompileValidator(schema);
|
|
87
|
+
validate = getOrCompileValidator(schema as Record<string, unknown>);
|
|
70
88
|
} catch (e) {
|
|
71
89
|
throw new Error(`Invalid JSON Schema: ${(e as Error).message}`);
|
|
72
90
|
}
|
|
@@ -86,5 +104,122 @@ export default function structuredOutputExtension(pi: PiAPI): void {
|
|
|
86
104
|
details: data as Record<string, unknown>,
|
|
87
105
|
};
|
|
88
106
|
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Workflow hook ──────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 从 tool 执行结果里提取错误文本。
|
|
114
|
+
*
|
|
115
|
+
* Pi 框架在 tool execute 抛错时,构造 `{ content: [{ type: "text", text }] }`
|
|
116
|
+
* 塞进 result.content[0].text(见 extensions/unified-hooks 的 extractErrorText 及其
|
|
117
|
+
* 文档:SDK 事件结构里没有独立 errorMessage 字段,错误文本只能从 result.content 里取)。
|
|
118
|
+
* 这里防御性取多种结构,取不到就返回 undefined(调用方降级为通用提示)。
|
|
119
|
+
*/
|
|
120
|
+
function extractToolErrorText(result: unknown): string | undefined {
|
|
121
|
+
// 常见结构:{ content: [{ type: "text", text: "..." }] }
|
|
122
|
+
if (typeof result === "object" && result !== null) {
|
|
123
|
+
const content = (result as Record<string, unknown>).content;
|
|
124
|
+
if (Array.isArray(content)) {
|
|
125
|
+
for (const item of content) {
|
|
126
|
+
if (typeof item === "object" && item !== null) {
|
|
127
|
+
const text = (item as Record<string, unknown>).text;
|
|
128
|
+
if (typeof text === "string" && text.length > 0) return text;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// 兜底:某些 tool 直接塞 { error: "..." }
|
|
133
|
+
const err = (result as Record<string, unknown>).error;
|
|
134
|
+
if (typeof err === "string" && err.length > 0) return err;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 注册 turn_end hook,检查模型是否成功调用 structured-output 工具。
|
|
141
|
+
* 未成功时通过 pi.sendUserMessage({deliverAs:"steer"}) 注入 steering message 重试。
|
|
142
|
+
*
|
|
143
|
+
* 两种失败形态都会触发 steer:
|
|
144
|
+
* 1. 完全没调用(soCallCount === 0)→ 注入"必须调用"提示 + 正确 schema
|
|
145
|
+
* 2. 调了但全是 isError(soCallCount > 0 && !soSucceededEver)→ 注入具体校验错误
|
|
146
|
+
* + 正确 schema。旧实现在此处撒手交给 Pi 自然修正,但模型遇到 "Invalid JSON Schema"
|
|
147
|
+
* 时无法自行修正(它不知道正确 schema 长什么样),实测会放弃 → 子进程正常退出 →
|
|
148
|
+
* workflow 把单点失败放大成整批崩溃。故此处主动 steer 并回灌错误细节。
|
|
149
|
+
*
|
|
150
|
+
* 检测时序:Pi 保证同 turn 内所有 tool_execution_end 都在 turn_end 之前触发,
|
|
151
|
+
* 故 turn_end 读取的状态已反映本 turn 全部 tool 调用结果。
|
|
152
|
+
*/
|
|
153
|
+
function setupWorkflowHook(pi: PiAPI, schemaJson: string): void {
|
|
154
|
+
let soCallCount = 0;
|
|
155
|
+
let soSucceededEver = false;
|
|
156
|
+
let hookRetryCount = 0;
|
|
157
|
+
// 最近一次 structured-output 调用的错误文本(isError=true 时从 result.content 提取)。
|
|
158
|
+
// turn_end 据此决定 steer 消息是"必须调用"还是"修正后重试"。
|
|
159
|
+
let lastSchemaError = "";
|
|
160
|
+
|
|
161
|
+
// 追踪 structured-output 调用结果:
|
|
162
|
+
// 成功 → soSucceededEver=true(终态,后续不再干预)
|
|
163
|
+
// 失败 → soCallCount++,记录 lastSchemaError,由 turn_end 决定是否 steer 重试
|
|
164
|
+
pi.on("tool_execution_end", async (event: unknown) => {
|
|
165
|
+
const e = event as { toolName: string; isError: boolean; result?: unknown };
|
|
166
|
+
if (e.toolName !== TOOL_NAME) return;
|
|
167
|
+
soCallCount++;
|
|
168
|
+
if (!e.isError) {
|
|
169
|
+
soSucceededEver = true;
|
|
170
|
+
} else {
|
|
171
|
+
lastSchemaError = extractToolErrorText(e.result) ?? "structured-output call failed";
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
pi.on("turn_end", async (event: unknown) => {
|
|
176
|
+
// 已经成功调用过 structured-output,不再干预
|
|
177
|
+
if (soSucceededEver) return;
|
|
178
|
+
|
|
179
|
+
// 完全没调用 OR 调了但全是失败 → 都需要 steer。两种情况共用重试上限与计数。
|
|
180
|
+
// stopReason="toolUse" → 模型还在调工具链,不需要干预
|
|
181
|
+
const e = event as { message?: { stopReason?: string } };
|
|
182
|
+
if (e.message?.stopReason === "toolUse") return;
|
|
183
|
+
|
|
184
|
+
// 超过重试上限:放弃,让子进程自然结束(调用方据 result.error 判定失败)
|
|
185
|
+
if (hookRetryCount >= MAX_HOOK_RETRIES) return;
|
|
186
|
+
|
|
187
|
+
const calledButFailed = soCallCount > 0;
|
|
188
|
+
// 按本 turn 重置计数;lastSchemaError 在下次 steer 消息构造后自然覆盖
|
|
189
|
+
soCallCount = 0;
|
|
190
|
+
hookRetryCount++;
|
|
191
|
+
|
|
192
|
+
const reminder = calledButFailed
|
|
193
|
+
? [
|
|
194
|
+
"[MANDATORY] Your structured-output call FAILED validation:",
|
|
195
|
+
lastSchemaError,
|
|
196
|
+
"",
|
|
197
|
+
`The correct schema is: ${schemaJson}`,
|
|
198
|
+
"Call the structured-output tool AGAIN with data conforming to this schema.",
|
|
199
|
+
"Do NOT output the result as text — call the tool.",
|
|
200
|
+
].join("\n")
|
|
201
|
+
: [
|
|
202
|
+
"[MANDATORY] You MUST call the structured-output tool now.",
|
|
203
|
+
"Your task requires a structured output. Do NOT respond with plain text.",
|
|
204
|
+
`Call the structured-output tool with: schema = ${schemaJson}, data = <your result>`,
|
|
205
|
+
"This is enforced by the workflow system. Just call the tool.",
|
|
206
|
+
].join("\n");
|
|
207
|
+
|
|
208
|
+
lastSchemaError = "";
|
|
209
|
+
pi.sendUserMessage(reminder, { deliverAs: "steer" });
|
|
89
210
|
});
|
|
90
211
|
}
|
|
212
|
+
|
|
213
|
+
// ── Extension entry ────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
export default function structuredOutputExtension(pi: PiAPI): void {
|
|
216
|
+
const schemaEnv = process.env[ENV_SCHEMA];
|
|
217
|
+
|
|
218
|
+
// Always register the tool so it's available in all sessions (interactive, workflow, etc.)
|
|
219
|
+
pi.registerTool(createToolDefinition());
|
|
220
|
+
|
|
221
|
+
if (schemaEnv) {
|
|
222
|
+
// ── Workflow 模式:额外注册 hook 强制调用 ──
|
|
223
|
+
setupWorkflowHook(pi, schemaEnv);
|
|
224
|
+
}
|
|
225
|
+
}
|