@zhushanwen/pi-structured-output 0.3.2 → 0.3.4
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 +194 -43
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* 最多重试 2 次,防止无限循环。
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import Ajv, { type ValidateFunction } from "ajv";
|
|
15
14
|
import { Type } from "@sinclair/typebox";
|
|
15
|
+
import Ajv, { type ValidateFunction } from "ajv";
|
|
16
16
|
|
|
17
17
|
/** Pi Extension API — typed as any because shared stub has no real signatures */
|
|
18
18
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -25,7 +25,12 @@ const MAX_HOOK_RETRIES = 2;
|
|
|
25
25
|
// ── Ajv WeakMap cache ─────────────────────────────────────────
|
|
26
26
|
const ajvCache = new WeakMap<object, ValidateFunction>();
|
|
27
27
|
|
|
28
|
-
function getOrCompileValidator(schema: Record<string, unknown>): ValidateFunction {
|
|
28
|
+
function getOrCompileValidator(schema: Record<string, unknown> | boolean): ValidateFunction {
|
|
29
|
+
// boolean 根 schema(true=接受一切,false=拒绝一切)是合法 draft-07,
|
|
30
|
+
// 但 boolean 不能做 WeakMap key,故不缓存(编译结果恒定,重复编译无副作用)。
|
|
31
|
+
if (typeof schema === "boolean") {
|
|
32
|
+
return new Ajv({ strict: false }).compile(schema);
|
|
33
|
+
}
|
|
29
34
|
const cached = ajvCache.get(schema);
|
|
30
35
|
if (cached) return cached;
|
|
31
36
|
|
|
@@ -35,6 +40,175 @@ function getOrCompileValidator(schema: Record<string, unknown>): ValidateFunctio
|
|
|
35
40
|
return validate;
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
// ── Schema-shape guards (swap detection + silent-corruption prevention) ──
|
|
44
|
+
//
|
|
45
|
+
// 核心问题:schema 和 data 参数都用 Type.Unknown(),结构无差别。弱模型常把答案
|
|
46
|
+
// 塞进 schema、把形状塞进 data。因 ajv strict:false 把无 keyword 的对象编译成
|
|
47
|
+
// "接受一切" 的 validator,互换后会校验通过、存垃圾、无报错(静默腐败)。
|
|
48
|
+
// 这组守卫在编译前拦截两类形态:互换(schema 像数据 + data 像 schema)和
|
|
49
|
+
// keyword-less schema({} / {a:1} 这种会被 ajv 静默放行)。
|
|
50
|
+
|
|
51
|
+
/** JSON Schema draft-07 识别 keyword。只要 schema 含其一就认为是"真 schema"。 */
|
|
52
|
+
const SCHEMA_KEYWORDS = [
|
|
53
|
+
// 核心类型
|
|
54
|
+
"type",
|
|
55
|
+
// object
|
|
56
|
+
"properties", "required", "additionalProperties", "patternProperties",
|
|
57
|
+
"minProperties", "maxProperties",
|
|
58
|
+
// array
|
|
59
|
+
"items", "additionalItems", "minItems", "maxItems", "uniqueItems",
|
|
60
|
+
// enum / const
|
|
61
|
+
"enum", "const",
|
|
62
|
+
// 组合
|
|
63
|
+
"allOf", "anyOf", "oneOf", "not",
|
|
64
|
+
// 条件验证(draft-07)
|
|
65
|
+
"if", "then", "else",
|
|
66
|
+
// 依赖与约束
|
|
67
|
+
"dependencies", "propertyNames", "contains",
|
|
68
|
+
// 引用与定义
|
|
69
|
+
"$ref", "$id", "$defs", "definitions",
|
|
70
|
+
// 数值
|
|
71
|
+
"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf",
|
|
72
|
+
// 字符串
|
|
73
|
+
"minLength", "maxLength", "pattern", "format",
|
|
74
|
+
] as const;
|
|
75
|
+
|
|
76
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasSchemaKeyword(obj: Record<string, unknown>): boolean {
|
|
81
|
+
return SCHEMA_KEYWORDS.some((keyword) => keyword in obj);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 错误回显长度上限(截断长 schema/data,避免错误消息爆炸)。 */
|
|
85
|
+
const ECHO_MAX_CHARS = 200;
|
|
86
|
+
|
|
87
|
+
function echo(value: unknown): string {
|
|
88
|
+
let str: string;
|
|
89
|
+
try {
|
|
90
|
+
// JSON.stringify(undefined) 返回 undefined(不是 throw),需 ?? 兜底,
|
|
91
|
+
// 否则后续 str.length 会 "Cannot read properties of undefined"。
|
|
92
|
+
str = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
|
|
93
|
+
} catch {
|
|
94
|
+
str = String(value);
|
|
95
|
+
}
|
|
96
|
+
return str.length <= ECHO_MAX_CHARS ? str : `${str.slice(0, ECHO_MAX_CHARS)}...`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 尝试 JSON.parse;失败(malformed JSON)时保留原值,让 Ajv 拒绝。
|
|
101
|
+
* 模型有时把 schema/data 当 JSON 字符串传;parse 失败不是错误,保持原样让下游校验拒绝。
|
|
102
|
+
* catch 里有实质处理(决定返回原值),满足 taste/no-silent-catch。
|
|
103
|
+
*/
|
|
104
|
+
function tryParseJson(raw: unknown): unknown {
|
|
105
|
+
if (typeof raw !== "string") return raw;
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(raw);
|
|
108
|
+
} catch {
|
|
109
|
+
return raw; // malformed JSON → 保留原字符串,Ajv 会拒绝
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** turn_end event 是否可安全访问 message.stopReason(用于判断模型是否还在调工具链)。 */
|
|
114
|
+
function isTurnEndEvent(e: unknown): e is { message?: { stopReason?: string } } {
|
|
115
|
+
return typeof e === "object" && e !== null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** tool_execution_end event 结构守卫(替代直接 cast,配合 taste/no-unsafe-cast)。 */
|
|
119
|
+
function isToolExecutionEndEvent(
|
|
120
|
+
e: unknown,
|
|
121
|
+
): e is { toolName: unknown; isError: unknown; result?: unknown } {
|
|
122
|
+
return typeof e === "object" && e !== null && "toolName" in e && "isError" in e;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** swap 检测 + keyword-less schema 拒绝的纠错文案前缀,所有相关错误共用。 */
|
|
126
|
+
const CORRECT_USAGE_HINT =
|
|
127
|
+
"Correct: structured_output({schema:{type:'object',properties:{...}}, data:{...actual values}}). ";
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 执行 schema 校验。从 createToolDefinition.execute 抽出以便单元测试直接调用。
|
|
131
|
+
*
|
|
132
|
+
* 防御顺序(编译前拦截,治静默腐败的根):
|
|
133
|
+
* 1. 互换检测 — schema 像 data(无 keyword)且 data 像 schema(有 keyword)→ 抛纠错
|
|
134
|
+
* 2. keyword-less schema 拒绝 — schema 是对象但无任何识别 keyword({} / {a:1})
|
|
135
|
+
* → 抛 "no recognized keyword",否则 ajv strict:false 会编译成"接受一切"
|
|
136
|
+
* 3. ajv 编译失败 → 抛 "Invalid JSON Schema"(含回显)
|
|
137
|
+
* 4. 校验失败 → 抛 "Schema validation failed"(含回显)
|
|
138
|
+
*/
|
|
139
|
+
export async function executeStructuredOutput(params: {
|
|
140
|
+
schema: unknown;
|
|
141
|
+
data: unknown;
|
|
142
|
+
}): Promise<{
|
|
143
|
+
content: Array<{ type: "text"; text: string }>;
|
|
144
|
+
// data 可能是 primitive/array/object(根 schema 决定),故 details 为 unknown。
|
|
145
|
+
// 测试断言 toEqual(42)/toEqual(true)/toEqual(["a","b","c"]),不可窄化为 Record。
|
|
146
|
+
details: unknown;
|
|
147
|
+
}> {
|
|
148
|
+
// Normalize: some models pass schema/data as JSON strings instead of objects
|
|
149
|
+
const schema = tryParseJson(params.schema);
|
|
150
|
+
const data = tryParseJson(params.data);
|
|
151
|
+
|
|
152
|
+
// 1. 互换检测:schema 像数据(对象无 keyword)且 data 像 schema(对象有 keyword)。
|
|
153
|
+
// 这是最严重的静默腐败路径——若放行,ajv 会把"数据形态的 schema"编译成接受一切,
|
|
154
|
+
// 真正的 schema(此时在 data 里)被丢弃,校验通过并存入垃圾。
|
|
155
|
+
if (isPlainObject(schema) && !hasSchemaKeyword(schema) && isPlainObject(data) && hasSchemaKeyword(data)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
"Likely swapped: schema looks like data and data looks like a schema. "
|
|
158
|
+
+ CORRECT_USAGE_HINT
|
|
159
|
+
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 2. keyword-less schema 拒绝:治静默腐败的根。{} / {a:1} 这类对象会被
|
|
164
|
+
// ajv strict:false 编译成"接受一切"的 validator,模型把答案塞进 schema 时会静默通过。
|
|
165
|
+
if (isPlainObject(schema) && !hasSchemaKeyword(schema)) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
"Invalid JSON Schema: schema has no recognized keyword "
|
|
168
|
+
+ "(type/properties/items/enum/...). If you passed the answer value as schema, "
|
|
169
|
+
+ "you likely swapped schema and data. "
|
|
170
|
+
+ CORRECT_USAGE_HINT
|
|
171
|
+
+ `Received schema=${echo(schema)}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 3. ajv 编译。schema 此时可能是 object(过 keyword 检查)、boolean(合法 draft-07 根)、
|
|
176
|
+
// 或 string/number/array/null(非法 → 显式抛错给清晰提示)。getOrCompileValidator 只接受
|
|
177
|
+
// object|boolean,消除原先的 `as Record<string,unknown>` 不安全 cast。
|
|
178
|
+
let validate: ValidateFunction;
|
|
179
|
+
try {
|
|
180
|
+
if (isPlainObject(schema) || typeof schema === "boolean") {
|
|
181
|
+
validate = getOrCompileValidator(schema);
|
|
182
|
+
} else {
|
|
183
|
+
throw new Error(`schema must be a JSON Schema object or boolean, got ${typeof schema}`);
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Invalid JSON Schema: ${(e as Error).message}. `
|
|
188
|
+
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 4. 校验
|
|
193
|
+
const valid = validate(data);
|
|
194
|
+
if (!valid) {
|
|
195
|
+
const errors = validate.errors
|
|
196
|
+
?.map((err) => `${err.instancePath} ${err.message}`)
|
|
197
|
+
.join("; ");
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Schema validation failed: ${errors}. `
|
|
200
|
+
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
content: [
|
|
206
|
+
{ type: "text" as const, text: "Structured output recorded successfully." },
|
|
207
|
+
],
|
|
208
|
+
details: data,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
38
212
|
// ── Tool definition (shared between modes) ─────────────────────
|
|
39
213
|
|
|
40
214
|
function createToolDefinition() {
|
|
@@ -45,12 +219,19 @@ function createToolDefinition() {
|
|
|
45
219
|
"Return structured output validated against a JSON Schema. "
|
|
46
220
|
+ "Call this tool to produce validated JSON data. "
|
|
47
221
|
+ "Pass `schema` (a JSON Schema draft-07 object) and `data` (the value to validate).\n\n"
|
|
48
|
-
+ "
|
|
222
|
+
+ "schema describes the shape; data fills the values; they must match.\n\n"
|
|
223
|
+
+ "✅ Correct (full call): structured_output({schema:{type:'object',properties:{name:{type:'string'},age:{type:'number'}},required:['name']}, data:{name:'Alice',age:30}})\n"
|
|
49
224
|
+ "✅ Correct: schema={type:'array',items:{type:'string'}}, data=['a','b','c']\n"
|
|
50
|
-
+ "✅ Correct: schema={type:'string',enum:['low','medium','high']}, data='medium'\n
|
|
225
|
+
+ "✅ Correct: schema={type:'string',enum:['low','medium','high']}, data='medium'\n"
|
|
226
|
+
+ "✅ Correct: schema={type:'number',minimum:0,maximum:100}, data=42\n"
|
|
227
|
+
+ "✅ Correct: schema={type:'boolean'}, data=true\n\n"
|
|
51
228
|
+ "❌ Wrong: putting the answer in text instead of calling this tool\n"
|
|
52
229
|
+ "❌ 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)"
|
|
230
|
+
+ "❌ Wrong: schema={type:'object'} with data='hello' (string ≠ object)\n"
|
|
231
|
+
+ "❌ Wrong: structured_output({name:'Alice'}) — missing the schema/data envelope. Wrap as {schema:{...}, data:{name:'Alice'}}.\n"
|
|
232
|
+
+ "❌ Wrong: swapping schema and data (passing the answer as schema). The tool detects this as 'likely swapped' and rejects it.\n"
|
|
233
|
+
+ "❌ Wrong: merging schema and data into one object.\n"
|
|
234
|
+
+ "❌ Wrong: schema with no recognized JSON Schema keyword (e.g. {} or {answer:42}). The schema must describe shape via draft-07 keywords (type/properties/items/if-then-else/enum/...); a keyword-less object is rejected to prevent silent accept-all compilation.",
|
|
54
235
|
promptSnippet:
|
|
55
236
|
"Use structured-output to return validated JSON data. "
|
|
56
237
|
+ "Pass schema (JSON Schema draft-07) and data (your output). "
|
|
@@ -70,39 +251,9 @@ function createToolDefinition() {
|
|
|
70
251
|
}),
|
|
71
252
|
async execute(
|
|
72
253
|
_toolCallId: string,
|
|
73
|
-
params: { schema:
|
|
254
|
+
params: { schema: unknown; data: unknown },
|
|
74
255
|
) {
|
|
75
|
-
|
|
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
|
-
}
|
|
84
|
-
|
|
85
|
-
let validate: ValidateFunction;
|
|
86
|
-
try {
|
|
87
|
-
validate = getOrCompileValidator(schema as Record<string, unknown>);
|
|
88
|
-
} catch (e) {
|
|
89
|
-
throw new Error(`Invalid JSON Schema: ${(e as Error).message}`);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const valid = validate(data);
|
|
93
|
-
if (!valid) {
|
|
94
|
-
const errors = validate.errors
|
|
95
|
-
?.map((err) => `${err.instancePath} ${err.message}`)
|
|
96
|
-
.join("; ");
|
|
97
|
-
throw new Error(`Schema validation failed: ${errors}`);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return {
|
|
101
|
-
content: [
|
|
102
|
-
{ type: "text" as const, text: "Structured output recorded successfully." },
|
|
103
|
-
],
|
|
104
|
-
details: data as Record<string, unknown>,
|
|
105
|
-
};
|
|
256
|
+
return executeStructuredOutput(params);
|
|
106
257
|
},
|
|
107
258
|
};
|
|
108
259
|
}
|
|
@@ -162,13 +313,13 @@ function setupWorkflowHook(pi: PiAPI, schemaJson: string): void {
|
|
|
162
313
|
// 成功 → soSucceededEver=true(终态,后续不再干预)
|
|
163
314
|
// 失败 → soCallCount++,记录 lastSchemaError,由 turn_end 决定是否 steer 重试
|
|
164
315
|
pi.on("tool_execution_end", async (event: unknown) => {
|
|
165
|
-
|
|
166
|
-
if (
|
|
316
|
+
if (!isToolExecutionEndEvent(event)) return;
|
|
317
|
+
if (event.toolName !== TOOL_NAME) return;
|
|
167
318
|
soCallCount++;
|
|
168
|
-
if (
|
|
319
|
+
if (event.isError !== true) {
|
|
169
320
|
soSucceededEver = true;
|
|
170
321
|
} else {
|
|
171
|
-
lastSchemaError = extractToolErrorText(
|
|
322
|
+
lastSchemaError = extractToolErrorText(event.result) ?? "structured-output call failed";
|
|
172
323
|
}
|
|
173
324
|
});
|
|
174
325
|
|
|
@@ -178,8 +329,8 @@ function setupWorkflowHook(pi: PiAPI, schemaJson: string): void {
|
|
|
178
329
|
|
|
179
330
|
// 完全没调用 OR 调了但全是失败 → 都需要 steer。两种情况共用重试上限与计数。
|
|
180
331
|
// stopReason="toolUse" → 模型还在调工具链,不需要干预
|
|
181
|
-
|
|
182
|
-
if (
|
|
332
|
+
if (!isTurnEndEvent(event)) return;
|
|
333
|
+
if (event.message?.stopReason === "toolUse") return;
|
|
183
334
|
|
|
184
335
|
// 超过重试上限:放弃,让子进程自然结束(调用方据 result.error 判定失败)
|
|
185
336
|
if (hookRetryCount >= MAX_HOOK_RETRIES) return;
|