@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +158 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-structured-output",
3
- "version": "0.3.0",
3
+ "version": "0.3.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",
package/src/index.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
- * Structured Output Extension — 全局可用 tool
2
+ * Structured Output Extension — 条件激活的 schema 校验工具 + hook
3
3
  *
4
- * 始终注册 `structured-output` tool,AI 可在任何场景下调用。
5
- * 调用时传入 schema + data,扩展用 Ajv 验证 data 是否符合 schema。
4
+ * 激活模式:
5
+ * - 日常 pi(interactive / 普通 print):不设置 PI_WORKFLOW_SCHEMA,扩展不注册工具
6
+ * - workflow 子进程:agent-pool 设置 PI_WORKFLOW_SCHEMA=<json>,扩展注册工具 + hook
6
7
  *
7
- * workflow 场景:agent-pool 通过 prompt 指示 AI 调用此 tool 并传入 schema。
8
- * 普通对话场景:AI 需要返回结构化数据时自行调用。
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
- export default function structuredOutputExtension(pi: PiAPI): void {
36
- pi.registerTool({
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 when you need to produce structured data (JSON). "
42
- + "Pass the `schema` (JSON Schema object) and `data` (the value to validate). "
43
- + "The tool validates `data` against `schema` and returns it on success.",
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
- + "Workflow scripts: always use this tool instead of raw JSON in text.",
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
- "Pass a valid JSON Schema in the `schema` parameter.",
50
- "Pass the data to validate in the `data` parameter.",
51
- "Do not output JSON in text — use this tool instead.",
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.Object({}, {
55
- description: "JSON Schema object that `data` must conform to",
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.Any({
58
- description: "The value to validate against `schema`",
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
- const { schema, data } = params;
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
+ }