@zhushanwen/pi-structured-output 5.0.2 → 5.1.0

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.
@@ -0,0 +1,567 @@
1
+ /**
2
+ * 有界失败闸门(D3,U2 / M2)——G2「失败必有界」的执行体。
3
+ *
4
+ * 通道选型(设计 §6.3;pi 0.84.1 实装,登记 PS-20):
5
+ * 参数层失败(事故主形态)在 pi-ai validateToolArguments 抛错(validation.js:272-273)、
6
+ * 走 agent-loop 的 immediate error 路径(agent-loop.js:404 校验 → :445-451 catch)
7
+ * ——execute 不被调用、beforeToolCall 永不触发;唯一可靠
8
+ * 的计数通道是 tool_execution_end 事件(sequential/parallel 两路径的 immediate
9
+ * 分支均 emitToolExecutionEnd:agent-loop.js:277/:318/:348/:358)。
10
+ *
11
+ * 状态机:
12
+ * - 只统计 name=structured-output 的 isError 事件;其他工具的成功/失败均忽略。
13
+ * - 错误签名归一化(审查项#7 签名哈希化):截掉 "Received arguments:" 起的实参
14
+ * 回显后,提取错误字段/路径 token 集合排序哈希为签名——集合不变 = 模型无进展
15
+ * (重复同一批字段错误),集合变化(含缩小 = 渐进修复)= 模型在推进。旧 500c
16
+ * 前缀截断在大 schema 下会把「修复排序靠后字段」(消息前缀不变)误折叠成同签名。
17
+ * required 错误的完整缺失字段列表从 message 解析并入 token(pi-ai bullet 路径位
18
+ * 仅含 requiredProperties[0],只取路径位会把「修好非首字段」折叠成同签名误杀)。
19
+ * AP 错误(D4 注入 additionalProperties:false,错误行无字段名)按错误行路径位
20
+ * 下钻实参回显取该层 keys,以 key:<path>:<name> 形式并入——keys 是结构信息不是
21
+ * 值:该层 keys 集合缩小 = 模型在删多余字段 = 真实进展;实参值变化(keys 不变)
22
+ * 不产生新签名;嵌套 AP(路径位 a / a.b,R4-F2)下钻到嵌套层而非顶层——顶层
23
+ * keys 恒不变会把嵌套场景折叠成同签名误杀。AP 行检测为行级固定文案精确匹配
24
+ * (R4-F3,/additional propert/i 子串版会被字段名含该字样的非 AP 错误伪触发)。
25
+ * keys 并入按桶门控(仅错误行块含 AP 错误行时生效,见 normalizeErrorSignature
26
+ * 的并集恒定陷阱注释)——required/格式类错误行天然携带字段名,keys 并入反而
27
+ * 制造对流失衡。
28
+ * - 连续同签名失败达 MAX_CONSECUTIVE_FAILURES(3)→ terminal 态:
29
+ * 写日志(stderr + appendEntry 双通道,含 §5.2 形态 b 恢复指引)后
30
+ * ctx.abort()(停当前 turn,截断 token 燃烧窗口)+ ctx.shutdown() 优雅终止
31
+ * 子进程(RPC mode 在 agent_settled 后 exit),并武装 15s 兜底硬退 timer
32
+ * (R3 F-2 bounded teardown,覆盖 pi 挂死不 settle 的异常态)。
33
+ * - 成功调用清零(模型走通即无循环)。
34
+ *
35
+ * 与 workflow-hook 的关系:terminal 态经 onTerminal 回调标记 RetryState.terminal,
36
+ * turn_end hook 据此不再 steer(防御性保留——shutdown 正常生效时进程已终止,
37
+ * 该分支是 shutdown 失败路径下的保险)。
38
+ *
39
+ * 诚实边界(设计 §6.3):闸门只终止「同签名无进展」这一种循环形态;签名不断
40
+ * 变化的长尾低效不触发闸门,仍由 workflow 层 maxTurns 兜底。
41
+ */
42
+
43
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
44
+
45
+ import { isPlainObject, isToolExecutionEndEvent } from "./schema-guards.js";
46
+ import {
47
+ extractToolErrorText,
48
+ SIGNATURE_MAX_CHARS,
49
+ truncateText,
50
+ } from "./text-primitives.js";
51
+
52
+ type PiAPI = ExtensionAPI;
53
+
54
+ // 依赖方向说明:本模块 → text-primitives(共享叶节点,导出复用勿复制)。原先经
55
+ // workflow-hook 取 extractToolErrorText 构成的模块环已破除——双方互引的纯函数/常量
56
+ // 下沉到 text-primitives,依赖图单向(loop-gate → text-primitives ← workflow-hook)。
57
+
58
+ /** 与 tool-definition.ts 的 TOOL_NAME 对应。 */
59
+ const TOOL_NAME = "structured-output";
60
+
61
+ /** 连续同签名失败阈值(设计 §6.3:对齐 qwen-code 的 3;workflow 子进程单用途短会话,更快失败更省)。 */
62
+ export const MAX_CONSECUTIVE_FAILURES = 3;
63
+
64
+ /** 实参回显起点标记(pi-ai validation.js 的 errorMessage 格式:错误行 + 空行 + 本标记 + 回显)。 */
65
+ const ARGS_ECHO_MARKER = "Received arguments:";
66
+
67
+ /**
68
+ * TypeBox required message 形态(typebox locale en_US 逐字核实):
69
+ * "must have required properties X, Y, Z"——单缺失也是复数 "properties",字段名为
70
+ * 裸标识符 ", " 连接。解析恢复完整缺失字段列表;形态不符 → undefined(走路径位)。
71
+ */
72
+ const REQUIRED_MESSAGE_RE = /must have required propert(?:y|ies) (.+)$/;
73
+
74
+ function parseRequiredMessageFields(message: string): string[] | undefined {
75
+ const m = message.match(REQUIRED_MESSAGE_RE);
76
+ if (!m) return undefined;
77
+ const fields = m[1]!
78
+ .split(",")
79
+ .map((s) => s.trim())
80
+ .filter((s) => /^[\w.\-/\[\]]+$/.test(s));
81
+ return fields.length > 0 ? fields : undefined;
82
+ }
83
+
84
+ /**
85
+ * required bullet 的路径位 = 父路径 + requiredProperties[0](pi-ai formatValidationPath)。
86
+ * 剥掉末尾首字段段得父路径前缀("outer.inner" → "outer.";路径位即首字段 → ""),
87
+ * 供 message 中每个缺失字段还原成全路径 token。形态意外(路径位不以首字段结尾)时
88
+ * 退化为整段路径做前缀——token 仍确定性地区分不同缺失集合。
89
+ */
90
+ function requiredBulletPrefix(bulletPath: string, firstField: string): string {
91
+ if (bulletPath === firstField) return "";
92
+ const suffix = `.${firstField}`;
93
+ const base = bulletPath.endsWith(suffix) ? bulletPath.slice(0, -suffix.length) : bulletPath;
94
+ return `${base}.`;
95
+ }
96
+
97
+ /**
98
+ * AP(additionalProperties)错误行标记(R4-F3 精确化:行级固定文案匹配)。
99
+ *
100
+ * 旧实现 /additional propert/i 大小写不敏感子串匹配会被「字段名/字段列表恰好含该
101
+ * 字样」的非 AP 错误伪触发(如 required message 列出名为 "additional properties"
102
+ * 的字段)——keys 分桶被错误激活,与常规 token 并集对流(同分桶陷阱的伪 AP 变体,
103
+ * 漏杀方向)。精确化后逐行匹配路径位之后的固定文案,字段名在冒号前不可能命中:
104
+ *
105
+ * 覆盖两种实装形态(均为固定文案,字段名不可能携带):
106
+ * 1. pi-ai 参数层 bullet(pi-ai 0.84.1 dist/utils/validation.js + typebox 1.3.7
107
+ * Compile 探针核实:message 恒为小写 "must not have additional properties",
108
+ * 行形态 ` - <path>: must not have additional properties`)——行级后缀匹配
109
+ * AP_LINE_SUFFIX_RE;路径位即 AP 层路径(root / a / a.b / list.0,
110
+ * formatValidationPath 对 instancePath 点分),供嵌套 keys 下钻(R4-F2)。
111
+ * 2. 日常模式 ajv(execute.ts instancePath 拼接形态,ajv locale 模板大写 "must
112
+ * NOT have additional properties")——模板词组含空格与固定语序,字段名不可能
113
+ * 携带。该形态无 "Received arguments:" 回显段,keys 分桶天然不激活
114
+ * (hasApError 仅作标记;闸门仅 workflow 模式装配,此形态实为防御性保留)。
115
+ */
116
+ const AP_LINE_SUFFIX_RE = /^(.*):\s*must not have additional properties\s*$/;
117
+ const AP_AJV_MESSAGE_RE = /must NOT have additional properties/;
118
+ /** pi-ai bullet message 位(冒号后)的 AP 固定文案原文(见上方探针核实记录)。 */
119
+ const AP_BULLET_MESSAGE = "must not have additional properties";
120
+
121
+ /**
122
+ * 从(已剔除实参回显的)错误文本提取字段/路径 token 集合——修复进展的坐标:
123
+ * 修一个字段必然改变失败字段集合,而消息前缀可能纹丝不动(大 schema 下错误行
124
+ * 按序排列,靠后字段的修复不进 500c 前缀——旧前缀方案的误杀根源)。
125
+ *
126
+ * 覆盖两种错误形态(pi 实装版核实的 errorMessage 结构):
127
+ * - pi-ai 参数层 bullet 行(validation.js:` - ${formatValidationPath(error)}: ${message}`):
128
+ * enum/类型等错误的字段名在 bullet 路径位:` - assessments.0.impact: must be string`。
129
+ * required 是例外(F1)——formatValidationPath 只把 requiredProperties[0] 放进
130
+ * 路径位,其余缺失字段名只在 message("must have required properties alpha, beta,
131
+ * gamma");本函数解析 message 把完整缺失列表并入 token,否则「修好非首字段」
132
+ * (列表收缩 = 真实进展)会因路径位不变被折叠成同签名、3 次误杀。
133
+ * - 日常模式 ajv instancePath(execute.ts 拼接 `${err.instancePath} ${err.message}`):
134
+ * `Schema validation failed: /count must be number`
135
+ *
136
+ * 有意决策(披露):token 集合只含字段/路径、不含错误类型——同一字段换约束试错
137
+ * ("must be string" → "must be number")折叠为同签名。权衡:换取「集合不变 = 无进展」
138
+ * 的强判定;代价是弱模型对单字段多约束的试错容忍度下降(同字段反复错达阈值即闸门)。
139
+ *
140
+ * 同时报告错误行块是否含 AP 错误行(hasApError)——normalizeErrorSignature 据此
141
+ * 门控 echo keys 并入(分桶动机见该函数注释);并收集 AP 错误行的路径位集合
142
+ *(apPaths,R4-F2)——嵌套 AP 场景(schema 属性 a 下 AP:false,模型在 a 里逐个
143
+ * 删多余字段)错误行路径位是 a(非 root),顶层 keys 恒不变 → 误杀;keys 必须
144
+ * 按路径位下钻到该层才有进展信号(见 keysAtPath)。
145
+ *
146
+ * 提取不到 token(非校验类错误文本,如 "structured-output call failed"/网络错误)
147
+ * 返回空数组——调用方 fallback 到 SIGNATURE_MAX_CHARS 前缀。
148
+ */
149
+ function extractErrorFieldTokens(errorLines: string): {
150
+ tokens: string[];
151
+ hasApError: boolean;
152
+ /** AP 错误行路径位集合(root / a / a.b / list.0)——嵌套 keys 下钻坐标。 */
153
+ apPaths: string[];
154
+ } {
155
+ const tokens = new Set<string>();
156
+ const apPaths = new Set<string>();
157
+ // 形态 1:bullet 行(路径位 + message);字符类覆盖点分路径/数组索引/斜杠
158
+ const bulletLine = /(?:^|\n)[ \t]*-[ \t]+([\w.\-/\[\]]+):[ \t]*([^\n]+)/g;
159
+ // 形态 2:行首/空白/分号后的 ajv instancePath(/a/b/c)——排除紧贴前字符的斜杠
160
+ //(避免把 bullet 路径里的 a/b 拆出半截 token 与形态 1 重复)
161
+ const slashPath = /(?:^|[\s;])\/([\w.\-/\[\]]+)/g;
162
+ for (const m of errorLines.matchAll(bulletLine)) {
163
+ const path = m[1]!;
164
+ const message = m[2]!;
165
+ // AP bullet:message 位恒为固定文案(R4-F3 精确匹配)——路径位进下钻坐标集
166
+ if (message.trim() === AP_BULLET_MESSAGE) {
167
+ tokens.add(path);
168
+ apPaths.add(path);
169
+ continue;
170
+ }
171
+ const requiredFields = parseRequiredMessageFields(message);
172
+ if (!requiredFields) {
173
+ tokens.add(path);
174
+ continue;
175
+ }
176
+ for (const field of requiredFields) {
177
+ tokens.add(`${requiredBulletPrefix(path, requiredFields[0]!)}${field}`);
178
+ }
179
+ }
180
+ // 非 bullet 形态的 AP 行兜底(路径位含 bullet 字符类外字符——空格/unicode 等):
181
+ // 行级后缀匹配(R4-F3 规定式 AP_LINE_SUFFIX_RE)并从冒号前抠路径位,同样进下钻
182
+ // 坐标;bullet 已命中的行 Set 去重,无重复贡献。
183
+ for (const line of errorLines.split("\n")) {
184
+ const m = line.replace(/^[ \t]*-[ \t]+/, "").match(AP_LINE_SUFFIX_RE);
185
+ if (m) apPaths.add(m[1]!.trim());
186
+ }
187
+ for (const m of errorLines.matchAll(slashPath)) {
188
+ tokens.add(m[1]!);
189
+ }
190
+ return {
191
+ tokens: [...tokens],
192
+ hasApError: apPaths.size > 0 || AP_AJV_MESSAGE_RE.test(errorLines),
193
+ apPaths: [...apPaths],
194
+ };
195
+ }
196
+
197
+ /** FNV-1a 32-bit 参数:offset basis / prime(零依赖确定性哈希;签名仅用于等值比较,非加密场景)。 */
198
+ const FNV_OFFSET_BASIS = 0x811c9dc5;
199
+ const FNV_PRIME = 0x01000193;
200
+ /** 32-bit 哈希输出为 8 位十六进制。 */
201
+ const HEX_RADIX = 16;
202
+ const HEX_WIDTH = 8;
203
+
204
+ function fnv1aHex(input: string): string {
205
+ let hash = FNV_OFFSET_BASIS;
206
+ for (let i = 0; i < input.length; i++) {
207
+ hash ^= input.charCodeAt(i);
208
+ hash = Math.imul(hash, FNV_PRIME);
209
+ }
210
+ return (hash >>> 0).toString(HEX_RADIX).padStart(HEX_WIDTH, "0");
211
+ }
212
+
213
+ /**
214
+ * 字段集合规范形 → 哈希:排序去重后 join(token 字符类不含逗号,join 无歧义)。
215
+ * 前缀带 token 数便于 appendEntry 日志肉眼判读(同数不同集靠哈希区分)。
216
+ */
217
+ function fieldSetSignature(tokens: string[]): string {
218
+ const canonical = [...tokens].sort().join(",");
219
+ return `fields(${tokens.length})#${fnv1aHex(canonical)}`;
220
+ }
221
+
222
+ /** 签名 fallback 截断:裸切片不加 "..."(既有行为——上限即 500,锁定于测试)。 */
223
+ function truncateSignature(text: string): string {
224
+ return text.length <= SIGNATURE_MAX_CHARS ? text : text.slice(0, SIGNATURE_MAX_CHARS);
225
+ }
226
+
227
+ /**
228
+ * 按点分路径下钻实参对象,取该层 keys(R4-F2 嵌套 AP)。
229
+ *
230
+ * 路径位语义(pi-ai 0.84.1 formatValidationPath 探针核实):"root" = 实参对象本身
231
+ * (根级 instancePath 为空的哨兵);"a.b" / "list.0" = instancePath 点分下钻
232
+ * (数组元素下标走数字段)。与「恰好有名为 root 的属性」的歧义由哨兵优先裁决
233
+ *(取实参顶层 keys,不向下钻)——与 pi-ai 自身的渲染歧义同构,根级 AP 是主形态。
234
+ *
235
+ * 演进语义:该层 keys 集合缩小(模型在删该层多余字段)= 新签名——与根级同一
236
+ * 「keys 是结构信息」契约。下钻中断(路径不存在 / 中间层非容器 / 目标层非 plain
237
+ * object)该路径降级不贡献(返回空数组)——签名退化为错误行块口径,不比缺该路径更差。
238
+ */
239
+ function keysAtPath(args: Record<string, unknown>, path: string): string[] {
240
+ if (path === "root") return Object.keys(args);
241
+ let current: unknown = args;
242
+ for (const segment of path.split(".")) {
243
+ if (Array.isArray(current)) {
244
+ if (!/^\d+$/.test(segment)) return [];
245
+ const index = Number(segment);
246
+ if (index >= current.length) return [];
247
+ current = current[index];
248
+ continue;
249
+ }
250
+ if (!isPlainObject(current) || !(segment in current)) return [];
251
+ current = current[segment];
252
+ }
253
+ return isPlainObject(current) ? Object.keys(current) : [];
254
+ }
255
+
256
+ /**
257
+ * 从原始错误消息的 "Received arguments:" 段解析实参对象(AP 误杀修复的原料)。
258
+ *
259
+ * 背景(D4):additionalProperties:false 默认注入后,模型带多余字段时 pi-ai 0.84.1
260
+ * validation.js 报 " - root: must not have additional properties"——bullet 路径位
261
+ * 恒为 "root"(根级非 required 错误不进 formatValidationPath 特判,本机探针核实),
262
+ * 错误行块不含多余字段名,字段 token 恒同集合;而模型逐个删除多余字段是真实进展,
263
+ * 仅凭错误行块会折叠成同签名、3 次 terminal 误杀。实参回显段是
264
+ * JSON.stringify(toolCall.arguments, null, 2) 产物(validation.js 逐字核实),可
265
+ * JSON.parse——顶层 keys 是结构信息:keys 集合缩小 = 模型在删字段 = 进展 → 并入
266
+ * token 集合产生新签名;嵌套 AP(R4-F2)则按 AP 错误行路径位下钻取该层 keys
267
+ *(见 keysAtPath)。
268
+ *
269
+ * 与「剔除实参回显」既有语义的兼容区分(本意不变):剔除回显的本意是「实参值变化
270
+ * 不算进展」——keys 是结构不是值:值变化(keys 不变)不影响签名,既有语义保留;
271
+ * keys 变化(结构变化)才产生新签名。回显段缺失 / JSON 解析失败 / 非 plain object
272
+ * (null、数组、原始值)一律返回 undefined(不贡献 token)——签名退化为仅错误行
273
+ * 块口径(如 steer 截断后的残缺回显、适配层改写过 errorMessage 的场景),不会比
274
+ * 修复前更差。
275
+ *
276
+ * 调用方门控(并集恒定陷阱,见 normalizeErrorSignature 注释):本函数结果仅在错误
277
+ * 行块含 AP 错误行时以 key:<path>:<name> 前缀并入签名——required 渐进修复场景 keys
278
+ * 与缺失列表对流,无条件并入会使 token 并集恒定、签名恒定,3 次误杀(实测教训)。
279
+ */
280
+ function parseArgsEchoObject(errorText: string, markerIdx: number): Record<string, unknown> | undefined {
281
+ const echoSection = errorText.slice(markerIdx + ARGS_ECHO_MARKER.length).trim();
282
+ if (!echoSection) return undefined;
283
+ try {
284
+ const parsed: unknown = JSON.parse(echoSection);
285
+ return isPlainObject(parsed) ? parsed : undefined;
286
+ } catch {
287
+ return undefined;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * 错误签名归一化(审查项#7:字段/路径 token 集合哈希;token 分桶版):
293
+ * 1. 剔除 "Received arguments:" 起的实参回显的「值」——值变化不等于进展(既有
294
+ * 语义保留)。
295
+ * 2. 提取字段/路径 token 集合,桶语义:
296
+ * - 常规桶(无前缀):bullet 路径位 / required message 字段 / ajv instancePath
297
+ * (extractErrorFieldTokens 既有逻辑)——required/格式类错误行天然携带字段名,
298
+ * token 集合演化(含缩小)即进展坐标。
299
+ * - AP 桶(key:<path>:<name> 前缀):仅当错误行块含 AP 错误行(hasApError)时,
300
+ * 按 AP 错误行路径位(apPaths)下钻实参回显取该层 keys 并入——AP 错误行无
301
+ * 字段名,keys 集合是唯一进展坐标(keys 缩小 = 删多余字段 = 进展);嵌套 AP
302
+ * 场景路径位是嵌套层(a / a.b),顶层 keys 恒不变,必须下钻(R4-F2);路径
303
+ * 限定防多 AP 层并存时同名 key 跨层折叠,也防 keys 与常规桶同名字段混淆。
304
+ * 3. 集合不变 = 同签名(无进展);集合变化 = 新签名(渐进修复不再被折叠)。
305
+ * 4. 提取失败(无任何 token)fallback 到 500c 前缀硬上限(既有行为)。
306
+ *
307
+ * 并集恒定陷阱(keys 分桶动机,第四轮实测误杀教训):旧实现把 echo keys 无条件
308
+ * 并入常规 token(无前缀)。required 渐进修复场景下,模型每轮修好 1 个字段时该字段
309
+ * 从错误行的缺失列表「迁移」到实参回显 keys——缺失 token 集缩小恰好被 keys 集增大
310
+ * 抵消,token 并集恒定 → 签名恒定 → 第 3 次同签名 terminal 误杀(6 required 字段
311
+ * 每轮修 1 个的探针实测复现:三轮签名恒 fields(6)#…)。而对流只发生在 required 类
312
+ * 错误(错误行携带字段名 + 实参同步增长);AP 错误行无字段名、keys 是其唯一信号——
313
+ * 故按「是否存在 AP 错误行」门控 keys 并入:required/格式场景 keys 退出签名(进展
314
+ * 由错误行 token 独家刻画),AP 场景 keys 进 AP 桶(机制保留)。
315
+ *
316
+ * 归一化对任意错误文本安全(非校验类错误 / 回显段残缺均走 fallback 或退化口径)。
317
+ */
318
+ export function normalizeErrorSignature(errorText: string): string {
319
+ const markerIdx = errorText.indexOf(ARGS_ECHO_MARKER);
320
+ const errorLines = (markerIdx >= 0 ? errorText.slice(0, markerIdx) : errorText).trim();
321
+ const { tokens, hasApError, apPaths } = extractErrorFieldTokens(errorLines);
322
+ // echo keys 分桶门控(见上方并集恒定陷阱):仅 AP 错误行存在时并入,带路径限定前缀
323
+ //(key:<path>:<name>;嵌套层 keys 缩小 = 新签名,与根级同一演进契约)
324
+ if (hasApError && markerIdx >= 0) {
325
+ const args = parseArgsEchoObject(errorText, markerIdx);
326
+ if (args) {
327
+ for (const path of apPaths) {
328
+ for (const key of keysAtPath(args, path)) {
329
+ tokens.push(`key:${path}:${key}`);
330
+ }
331
+ }
332
+ }
333
+ }
334
+ const unique = [...new Set(tokens)];
335
+ return unique.length > 0 ? fieldSetSignature(unique) : truncateSignature(errorLines);
336
+ }
337
+
338
+ /**
339
+ * structured-output 失败循环闸门状态机(纯逻辑,可单测)。
340
+ *
341
+ * 转移表(loop-gate.test.ts 锁定):
342
+ * - onToolExecEnd(false) → 计数/签名清零(成功短路)
343
+ * - onToolExecEnd(true, text) → 签名同:计数++;签名异:计数=1(新一轮连续从此失败起算)
344
+ * - 计数达 MAX_CONSECUTIVE_FAILURES → terminal=true(不可逆;此后任何输入不再变化)
345
+ */
346
+ export class LoopGate {
347
+ terminal = false;
348
+ private lastSignature: string | null = null;
349
+ consecutiveFailures = 0;
350
+ /** 最近一次失败的原始终态文本(截断后),供 terminal 日志引用。 */
351
+ lastErrorText: string | null = null;
352
+
353
+ /** 记录一次 structured-output tool 执行结果。hasError = event.isError === true。 */
354
+ onToolExecEnd(hasError: boolean, errorText?: string): {
355
+ terminal: boolean;
356
+ /** 仅在「本次调用触发 terminal」时为 true(幂等:terminal 后恒 false)。 */
357
+ newlyTerminal: boolean;
358
+ } {
359
+ if (this.terminal) return { terminal: true, newlyTerminal: false };
360
+
361
+ if (!hasError) {
362
+ this.consecutiveFailures = 0;
363
+ this.lastSignature = null;
364
+ this.lastErrorText = null;
365
+ return { terminal: false, newlyTerminal: false };
366
+ }
367
+
368
+ const rawText = errorText ?? "structured-output call failed";
369
+ this.lastErrorText = truncateText(rawText, SIGNATURE_MAX_CHARS);
370
+ const signature = normalizeErrorSignature(rawText);
371
+ if (signature === this.lastSignature) {
372
+ this.consecutiveFailures++;
373
+ } else {
374
+ this.lastSignature = signature;
375
+ this.consecutiveFailures = 1;
376
+ }
377
+
378
+ if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
379
+ this.terminal = true;
380
+ return { terminal: true, newlyTerminal: true };
381
+ }
382
+ return { terminal: false, newlyTerminal: false };
383
+ }
384
+
385
+ /** 当前归一化签名(字段集合哈希或 fallback 前缀;无失败史时 null;测试与日志用)。 */
386
+ get signature(): string | null {
387
+ return this.lastSignature;
388
+ }
389
+ }
390
+
391
+ /** terminal 日志的 appendEntry customType(session.jsonl 持久化,不进 LLM 上下文)。 */
392
+ export const GATE_ENTRY_TYPE = "structured-output:gate";
393
+
394
+ /**
395
+ * setTimeout delay 安全域校验。[同源锚定] @zhushanwen/pi-subagent-workflow 的
396
+ * shared/timer-delay.ts assertSafeTimerDelay 本地副本——两包独立 npm 不能直接
397
+ * import(isObjectRootSchema 本地副本同例:跨包相对 import 在发布产物里悬空),
398
+ * 本包仅此一个 timer 入口,取最小面副本。语义同源:非有限值 / 超 2^31-1 的 delay
399
+ * 会被 Node 塌缩为 1ms 立即触发(语义反转:兜底窗口变成立即硬杀),fail-fast 不
400
+ * 静默 clamp(clamp 把配置错误变成静默语义漂移)。
401
+ */
402
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
403
+
404
+ /** 每秒毫秒数(teardown 日志 ms→s 换算用,no-magic-numbers)。 */
405
+ const MS_PER_SECOND = 1000;
406
+
407
+ export function assertSafeTimerDelay(ms: number, source: string): void {
408
+ if (!Number.isFinite(ms)) {
409
+ throw new Error(
410
+ `[structured-output] ${source} = ${ms} is not a finite number (NaN/±Infinity). `
411
+ + "Non-finite delays collapse to 1ms in Node setTimeout and fire immediately. "
412
+ + "Recovery: fix the constant/computation feeding this timer and retry.",
413
+ );
414
+ }
415
+ if (ms > MAX_TIMER_DELAY_MS) {
416
+ throw new Error(
417
+ `[structured-output] ${source} = ${ms} exceeds the Node setTimeout limit `
418
+ + `(${MAX_TIMER_DELAY_MS} ms = 2^31-1); larger delays silently collapse to 1ms and fire immediately. `
419
+ + `Recovery: clamp the value to <= ${MAX_TIMER_DELAY_MS} and retry.`,
420
+ );
421
+ }
422
+ }
423
+
424
+ /**
425
+ * terminal 后 bounded teardown 的兜底硬退窗口(ms)。
426
+ *
427
+ * 任务原案 10s(SIGTERM 前优雅窗口)+ 5s(SIGKILL 前余量)的信号分级在扩展上下文
428
+ * 不可达(pi 0.84.1 ExtensionContext 无子进程句柄/信号能力,见 armForceExitTeardown
429
+ * 注释的核实记录),合并为单步 process.exit 硬退窗口。
430
+ *
431
+ * 数据完整性权衡(照任务要求写明):session flush 在 shutdown 请求时已尽力——RPC
432
+ * mode 于 agent_settled 后 exit,session entry 逐条 append 落盘(writeTerminatedLog
433
+ * 的 entry 在 terminal 当下已写入);15s 优雅窗口远超正常 flush 需求(abort+shutdown
434
+ * 后正常退出秒级完成),兜底只覆盖「pi 挂死不 settle」的异常态——此时宁可硬退
435
+ *(父进程 SW 侧走「子进程结束未产出 structured-output」失败路径,stderr 已留原因)
436
+ * 也不无限烧 token。
437
+ */
438
+ export const TEARDOWN_FORCE_EXIT_MS = 15_000;
439
+
440
+ /**
441
+ * 兜底硬退 exit code:非零且 < 128(SW 侧 session-runner 的 SIGNAL_EXIT_CODE_THRESHOLD)
442
+ * → 父进程按「子进程自身报错」记录(stderr 缓冲已先行写明原因),不会与信号终止混淆。
443
+ */
444
+ const TEARDOWN_EXIT_CODE = 1;
445
+
446
+ /** 已武装的兜底硬退 timer:globalThis[Symbol.for] slot 持有(C-ext-06,照 notify-ledger
447
+ * 先例)——jiti 路径分裂加载多份模块时裸模块级 let 双实例各持 timer(失效模式良性:
448
+ * 至多双 timer 各自 process.exit,进程级幂等);slot 化后单介质同源,幂等再清语义不变。 */
449
+ const TEARDOWN_TIMER_SLOT_KEY = Symbol.for("@zhushanwen/pi-structured-output.loopGate.teardownTimer");
450
+
451
+ type TeardownTimerSlot = { current: ReturnType<typeof setTimeout> | undefined };
452
+
453
+ function getTeardownTimerSlot(): TeardownTimerSlot {
454
+ // globalThis 无 symbol 索引签名,但运行时支持 symbol 键——用 Reflect 安全读写
455
+ //(notify-ledger getNotifyLedgerSlot 同款)。TeardownTimerSlot 是运行时保证的
456
+ // 固定形状(本文件唯一写入点)。
457
+ let slot = Reflect.get(globalThis, TEARDOWN_TIMER_SLOT_KEY) as TeardownTimerSlot | undefined;
458
+ if (!slot) {
459
+ slot = { current: undefined };
460
+ Reflect.set(globalThis, TEARDOWN_TIMER_SLOT_KEY, slot);
461
+ }
462
+ return slot;
463
+ }
464
+
465
+ /**
466
+ * 武装 terminal 后的 bounded teardown 兜底:TEARDOWN_FORCE_EXIT_MS 后进程仍未退出
467
+ *(pi 挂死未 settle)则 process.exit 硬退。
468
+ *
469
+ * 方案依据(pi 0.84.1 实装核实,node_modules/@earendil-works/pi-coding-agent
470
+ * dist/core/extensions/types.d.ts + dist/core/extensions/runner.js):
471
+ * ExtensionContext 对外仅 shutdown()(优雅:置 shutdownRequested,agent_settled 后
472
+ * exit)与 abort()(中止当前 agent 操作),无子进程句柄/信号 API;扩展与 pi 子进程
473
+ * 同进程(loader 进程内加载),「向子进程发 SIGTERM/SIGKILL」在语义上不成立——扩展
474
+ * 能做的最大硬杀就是对自身 process.exit。故采用任务预设的兜底形态:abort+shutdown
475
+ * 优雅退出为主,定时 process.exit 兜底。
476
+ *
477
+ * timer 卫生:assertSafeTimerDelay 包裹(防未来常量演化溢出塌缩为 1ms 立即硬杀)+
478
+ * unref(不阻止 pi 在窗口内自然退出;自然退出时本 timer 随进程消亡不再开火)+
479
+ * terminal 路径幂等 clearTimeout(重复武装不叠加多个兜底 timer)。
480
+ */
481
+ export function armForceExitTeardown(): void {
482
+ const slot = getTeardownTimerSlot();
483
+ if (slot.current !== undefined) clearTimeout(slot.current);
484
+ assertSafeTimerDelay(TEARDOWN_FORCE_EXIT_MS, "structured-output gate teardown");
485
+ const timer = setTimeout(() => {
486
+ process.stderr.write(
487
+ `[structured-output gate] graceful shutdown did not complete within ${TEARDOWN_FORCE_EXIT_MS / MS_PER_SECOND}s; `
488
+ + "force-exiting (session flush was best-effort at shutdown request).\n",
489
+ );
490
+ process.exit(TEARDOWN_EXIT_CODE);
491
+ }, TEARDOWN_FORCE_EXIT_MS);
492
+ // unref:窗口内 pi 自然退出时不被本 timer 拖住(timer 随进程消亡,不再开火)
493
+ timer.unref();
494
+ slot.current = timer;
495
+ }
496
+
497
+ /**
498
+ * terminal 态日志(§5.2 形态 b):
499
+ * - stderr:子进程 stderr 直出(xyz-agent runtime 的 pi-*.jsonl tee / 本地探针可见)
500
+ * - appendEntry:session JSONL 持久化记录(事后排查通道,不进 LLM 上下文)
501
+ * 两者内容同源,指引文案逐字对齐设计 §5.2。
502
+ */
503
+ function writeTerminatedLog(pi: PiAPI, gate: LoopGate): void {
504
+ const lastError = gate.lastErrorText ?? "(no error text)";
505
+ const stderrLines = [
506
+ `[structured-output gate] Terminated: the same validation error occurred ${MAX_CONSECUTIVE_FAILURES} times consecutively; shutting down this single-purpose workflow subprocess.`,
507
+ `Last error: ${lastError}`,
508
+ "👉 (workflow 作者) 检查 workflow 脚本的 outputSchema 是否过苛(深嵌套/超长 required),或更换更强模型后重跑该步骤。",
509
+ ].join("\n");
510
+ // stderr 已销毁(EPIPE)时错误走流 error 事件而非同步 throw(同 cache-probe 惯例)
511
+ process.stderr.write(`${stderrLines}\n`);
512
+
513
+ try {
514
+ pi.appendEntry(GATE_ENTRY_TYPE, {
515
+ event: "terminated",
516
+ consecutiveFailures: MAX_CONSECUTIVE_FAILURES,
517
+ signature: gate.signature,
518
+ lastError,
519
+ guidance:
520
+ "Check the workflow script's outputSchema (too strict / deep nesting / long required) or switch to a stronger model, then re-run this step.",
521
+ });
522
+ } catch (err) {
523
+ // appendEntry 失败不阻断 shutdown——stderr 通道已落,此处补诊断(同 cache-probe 惯例)
524
+ process.stderr.write(
525
+ `[structured-output gate] appendEntry failed: ${err instanceof Error ? err.message : String(err)}\n`,
526
+ );
527
+ }
528
+ }
529
+
530
+ export interface LoopGateOptions {
531
+ /** terminal 触发时的回调(index.ts 用于标记 RetryState.terminal,hook 据此停 steer)。 */
532
+ onTerminal?: () => void;
533
+ }
534
+
535
+ /**
536
+ * 注册 tool_execution_end 闸门监听(仅 workflow 模式装配,见 index.ts)。
537
+ *
538
+ * terminal 触发时序(R3 F-2 bounded teardown):onTerminal 回调(同步,先标记
539
+ * hook 状态)→ 写日志(stderr + appendEntry 双通道,R3 F-3)→ ctx.abort()(中止
540
+ * 当前 agent 操作——截断「shutdown 请求后当前 turn 的 bash/read/流式继续跑、模型
541
+ * 继续烧 token」的窗口,~25s 实测窗口在 abort 后即止)→ ctx.shutdown()(RPC mode
542
+ * 置 shutdownRequested,agent_settled 后进程 exit(0),父进程走「子进程结束但未
543
+ * 产出 structured-output」的既有失败路径)→ armForceExitTeardown()(15s 兜底硬退,
544
+ * 覆盖 pi 挂死不 settle 的异常态;方案依据见该函数注释)。
545
+ */
546
+ export function setupLoopGate(pi: PiAPI, options: LoopGateOptions = {}): LoopGate {
547
+ const gate = new LoopGate();
548
+
549
+ pi.on("tool_execution_end", async (event: unknown, ctx: ExtensionContext) => {
550
+ if (!isToolExecutionEndEvent(event)) return;
551
+ if (event.toolName !== TOOL_NAME) return;
552
+
553
+ const outcome = gate.onToolExecEnd(
554
+ event.isError === true,
555
+ extractToolErrorText(event.result),
556
+ );
557
+ if (!outcome.newlyTerminal) return;
558
+
559
+ options.onTerminal?.();
560
+ writeTerminatedLog(pi, gate);
561
+ ctx.abort();
562
+ ctx.shutdown();
563
+ armForceExitTeardown();
564
+ });
565
+
566
+ return gate;
567
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 有界化纯文本原语 — loop-gate / workflow-hook 的共享叶节点(零依赖、无副作用)。
3
+ *
4
+ * 破环(结构度量门禁):原先 loop-gate → workflow-hook(extractToolErrorText)与
5
+ * workflow-hook → loop-gate(STEER_ERROR_MAX_CHARS / truncateText)构成模块环;
6
+ * 双方互引的纯函数/常量统一收敛到本模块,依赖图恢复单向:
7
+ * loop-gate → text-primitives ← workflow-hook
8
+ *
9
+ * 导出复用勿复制(审查项#1):新调用方一律 import 本模块,禁止拷贝实现。
10
+ */
11
+
12
+ /**
13
+ * 闸门签名侧截断上限:① normalizeErrorSignature fallback 分支(提取不到字段 token 的
14
+ * 非校验类错误文本降级为裸前缀做等值比较,消费方 loop-gate);② LoopGate.lastErrorText
15
+ * (terminal 日志引用,不进模型上下文,消费方 loop-gate)。与 steer 回灌错误块上限
16
+ * (STEER_ERROR_MAX_CHARS)语义独立(F5 拆分:签名只做比较原料、可随实现演化;steer
17
+ * 是模型可见文本预算),两上限不共享数值演化。
18
+ */
19
+ export const SIGNATURE_MAX_CHARS = 500;
20
+
21
+ /**
22
+ * steer 回灌错误块截断上限(workflow-hook 消费;审查项#1:pi-ai validation.js 的
23
+ * 实参回显无截断,大 payload 失败时单份 steer ≈11K chars)。截断保留首部 = 错误类型
24
+ * + 靠前字段名;完整 schema 形状由 reminder 内 schemaJson 全文另行完整携带,不依赖
25
+ * 错误块。
26
+ */
27
+ export const STEER_ERROR_MAX_CHARS = 500;
28
+
29
+ /**
30
+ * 截断到 max 字符(超出追加 "...")——有界化原语,勿复制(审查项#1:导出复用)。
31
+ * max 必显式传入:签名侧传 SIGNATURE_MAX_CHARS(loop-gate 内部),steer 侧传
32
+ * STEER_ERROR_MAX_CHARS(workflow-hook)——不设默认值,防止新调用方静默绑错语义。
33
+ */
34
+ export function truncateText(text: string, max: number): string {
35
+ return text.length <= max ? text : `${text.slice(0, max)}...`;
36
+ }
37
+
38
+ /**
39
+ * 从 tool 执行结果里提取错误文本。
40
+ *
41
+ * Pi 框架在参数层校验失败(immediate 路径)与 execute 抛错时,均构造
42
+ * `{ content: [{ type: "text", text }] }` 塞进 result.content[0].text
43
+ * (agent-loop.js createErrorToolResult;见 extensions/universal/unified-hooks 的
44
+ * extractErrorText 及其文档:SDK 事件结构里没有独立 errorMessage 字段,错误文本只能从
45
+ * result.content 里取)。loop-gate(D3)复用本函数提取签名原料,workflow-hook 取
46
+ * steer 回灌文本。
47
+ * 这里防御性取多种结构,取不到就返回 undefined(调用方降级为通用提示)。
48
+ */
49
+ export function extractToolErrorText(result: unknown): string | undefined {
50
+ // 常见结构:{ content: [{ type: "text", text: "..." }] }
51
+ if (typeof result === "object" && result !== null) {
52
+ const content = (result as Record<string, unknown>).content;
53
+ if (Array.isArray(content)) {
54
+ for (const item of content) {
55
+ if (typeof item === "object" && item !== null) {
56
+ const text = (item as Record<string, unknown>).text;
57
+ if (typeof text === "string" && text.length > 0) return text;
58
+ }
59
+ }
60
+ }
61
+ // 兜底:某些 tool 直接塞 { error: "..." }
62
+ const err = (result as Record<string, unknown>).error;
63
+ if (typeof err === "string" && err.length > 0) return err;
64
+ }
65
+ return undefined;
66
+ }