@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.
Files changed (2) hide show
  1. package/package.json +2 -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.0",
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
- "./src/index.ts"
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 = ajv.compile(schema);
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
- // Register tool passthrough parameters (schema is dynamic per session)
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(SYSTEM_PROMPT);
122
+ ctx.addSystemInstruction(systemPrompt);
85
123
  });
86
124
 
87
- // Enforcement: track tool calls via tool_execution_start flag
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
- hasStructuredOutputCall = true;
128
+ turnCallCount++;
93
129
  }
94
130
  });
95
131
 
96
132
  pi.on("turn_end", async () => {
97
- if (!hasStructuredOutputCall) {
98
- pi.sendUserMessage(ENFORCEMENT_MESSAGE);
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
- // Block non-workflow usage
103
- pi.on("tool_call", async (event: { toolName: string }) => {
104
- if (event.toolName === TOOL_NAME && !process.env[ENV_KEY]) {
105
- return {
106
- block: true as const,
107
- reason: "This tool is only available in workflow structured-output mode",
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
- return undefined;
148
+
149
+ pi.sendUserMessage(ENFORCEMENT_MESSAGE);
111
150
  });
112
151
  }