@zhushanwen/pi-structured-output 5.1.8 → 6.0.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.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @zhushanwen/pi-structured-output
2
+
3
+ 结构化输出 pi extension:让模型用经过 JSON Schema 校验的工具调用代替自由文本交付结果。按 `PI_WORKFLOW_SCHEMA` 环境变量装配两种变体——workflow 模式以引擎注入的权威 schema 强制约束产出,日常模式校验模型自报 schema 的数据。
4
+
5
+ ## 两种模式
6
+
7
+ 启动时读取 `PI_WORKFLOW_SCHEMA`(该 env 由 subagent workflow 引擎注入子进程):
8
+
9
+ | | workflow 模式(env 有值) | 日常模式(env 无值) |
10
+ |---|---|---|
11
+ | 工具参数 | 单参数——parameters 即权威 schema 本身 | 双参数 `{schema, data}` 自报形态 |
12
+ | 校验权威 | pi 参数层直接按权威 schema 校验模型 arguments,execute 透传不二次校验 | Ajv 校验自报 data(防御链见下) |
13
+ | 强制手段 | turn_end 未成功调用工具(完全未调用,或调用了但全部校验失败)→ steer 注入重试(整个子进程生命周期最多 2 次);同签名失败连续 3 次 → 写日志后 abort 当前 turn + shutdown 子进程,15s 内未退出则进程硬退兜底 | 无(普通工具,失败抛错由模型自修) |
14
+
15
+ ## 提供的工具
16
+
17
+ ### `structured-output`
18
+
19
+ **workflow 模式**(`PI_WORKFLOW_SCHEMA` 有值时注册):
20
+
21
+ - object 根 schema:arguments 即数据本身,如 `structured-output({ score: 8 })`
22
+ - 非 object 根 schema(array/string/number/boolean/组合根等):包装为 `{ value: <data> }`(tool call arguments 协议上必须是 object),错误路径带 `value.` 前缀
23
+ - 根级 `additionalProperties` 未声明时注入 `false`:模型多传的字段被参数层显式拒绝(作者显式声明则尊重不动)
24
+ - 注册期 fail-fast:非法 schema(非 object/boolean 根、boolean true、无任何关键字的 object)在子进程加载期即终止并指回 workflow 脚本的 schema 定义;schema 超 256KiB 时 stderr 提示精简/拆分(硬拒绝在引擎注入侧)
25
+
26
+ **日常模式**(交互式 pi 注册):
27
+
28
+ - `schema`:JSON Schema draft-07 对象
29
+ - `data`:待校验的值(原始类型/object/array 均可)
30
+
31
+ 日常模式防御链(编译前拦截,全部抛错并带回显与纠错提示):
32
+
33
+ 1. 互换检测——schema 像数据且 data 像 schema → 判定为参数装反,拒绝
34
+ 2. 无关键字 schema 拒绝——`{}` / `{a:1}` 会被 Ajv(`strict:false`)编译成"接受一切",静默放行垃圾数据
35
+ 3. Ajv 编译失败 → 报 Invalid JSON Schema
36
+ 4. 校验失败 → 报告失败字段路径
37
+
38
+ ## 安装
39
+
40
+ ```bash
41
+ # npm 方式(正式)
42
+ pi install npm:@zhushanwen/pi-structured-output
43
+
44
+ # 本地路径加载(开发调试;-e 为 --extension 简写,可多次传入)
45
+ pi -e /path/to/extensions/universal/structured-output
46
+ pi --extension /path/to/extensions/universal/structured-output
47
+ ```
48
+
49
+ ## 示例
50
+
51
+ 日常模式(模型自报 schema + data):
52
+
53
+ ```
54
+ structured-output({
55
+ schema: { type: "object", properties: { score: { type: "number" } }, required: ["score"] },
56
+ data: { score: 8 }
57
+ })
58
+ ```
59
+
60
+ workflow 模式(schema 由引擎注入,模型只提交数据):
61
+
62
+ ```
63
+ structured-output({ score: 8 }) # object 根:arguments 即数据
64
+ structured-output({ value: [1, 2, 3] }) # array 根:包装在 value 字段
65
+ ```
66
+
67
+ ## 文件结构
68
+
69
+ ```
70
+ structured-output/
71
+ ├── index.ts # 入口 — re-export src/index.ts
72
+ └── src/
73
+ ├── index.ts # 装配分岔:读 PI_WORKFLOW_SCHEMA 选择变体 + re-export
74
+ ├── tool-definition.ts # 双变体工具定义(workflow 单参数 / 日常双参数)
75
+ ├── execute.ts # 校验编排(workflow 透传 / 日常防御链)+ 根形态判定
76
+ ├── ajv-validator.ts # Ajv 编译缓存(WeakMap)
77
+ ├── schema-guards.ts # 形态守卫纯函数(互换检测 / 关键字识别 / JSON 解析回显)
78
+ ├── workflow-hook.ts # turn_end 强制调用 hook + RetryState 状态机
79
+ ├── loop-gate.ts # 同签名失败 ×3 有界闸门(abort + shutdown + 硬退兜底)
80
+ └── text-primitives.ts # 截断/错误块有界化共享原语
81
+ ```
82
+
83
+ ## License
84
+
85
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-structured-output",
3
- "version": "5.1.8",
3
+ "version": "6.0.0",
4
4
  "description": "Structured output tool for Pi — workflow schema enforcement via pi's parameter layer; interactive mode validates self-reported schemas with Ajv",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "ajv": "^8.17.0",
28
- "@zhushanwen/pi-ext-guards": "0.4.1"
28
+ "@zhushanwen/pi-ext-guards": "0.4.2"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@earendil-works/pi-coding-agent": "^0.84.4",
package/src/index.ts CHANGED
@@ -4,32 +4,26 @@
4
4
  * 装配分岔(D1,U1):读 PI_WORKFLOW_SCHEMA——
5
5
  * - 有值(workflow 子进程):注册 workflow 变体(parameters = 权威 schema 本身,
6
6
  * D4 根级 additionalProperties 注入 / P6 非 object 根 {value} 包装 / 注册期
7
- * fail-fast 防御都在 createWorkflowToolDefinition 内)+ turn_end 强制 hook
7
+ * fail-fast 防御都在 createWorkflowToolDefinition 内)+ 双闸门合一 hook
8
8
  * - 无值(日常 pi):注册日常变体(双参数自报形态,行为不变,G4)
9
9
  *
10
- * Hook 机制(仅 workflow 模式):
11
- * turn_end 时检查模型是否调用了 structured-output 工具。
12
- * 如果没调 → 通过 pi.sendUserMessage() 注入 steering message 强制调用。
13
- * 最多重试 2 次,防止无限循环。
10
+ * Hook 机制(仅 workflow 模式,D2 单状态机单 listener):
11
+ * turn_end 时检查模型是否调用了 structured-output 工具,没调/失败则 steer 注入
12
+ * 重试提醒(软闸门,最多重试 2 次)。同一状态机(WorkflowGate)上的硬杀闸门在
13
+ * 同签名校验失败达 3 次时 terminal:写日志(stderr + session JSONL 双通道,含恢复
14
+ * 指引)后 ctx.abort() + ctx.shutdown() 优雅终止子进程 + 15s 兜底硬退 timer——
15
+ * terminal 后软闸门不再 steer(同一份 terminal 事实,无跨对象接线;G2 与模型
16
+ * 配合度无关)。装配与 listener 明细见 workflow-hook.setupWorkflowHook。
14
17
  *
15
- * 失败闸门(仅 workflow 模式,D3/U2):
16
- * tool_execution_end 上计数同签名校验失败,连续 3 次 → terminal:写日志
17
- * (stderr + session JSONL 双通道,含恢复指引)后 ctx.abort()(停当前 turn)+
18
- * ctx.shutdown() 优雅终止子进程(RPC mode 于 agent_settled 后 exit),并武装
19
- * 15s 兑底硬退 timer(R3 F-2 bounded teardown:pi 0.84.1 ExtensionAPI 无子进程
20
- * 信号能力,扩展与子进程同进程,process.exit 是唯一硬杀手段;覆盖 shutdown
21
- * 请求后 pi 挂死不 settle 的异常态);同时标记 RetryState.terminal,turn_end
22
- * hook 不再 steer。
23
- *
24
- * 模块拆分(M4):实现体分布于 ajv-validator.ts(编译缓存)/
18
+ * 模块拆分(M4 + D2 合一):实现体分布于 ajv-validator.ts(编译缓存)/
25
19
  * schema-guards.ts(形态守卫)/ execute.ts(校验编排)/ tool-definition.ts(工具定义)/
26
- * workflow-hook.ts(hook + RetryState)/ loop-gate.ts(D3 失败闸门)。
27
- * 本文件仅剩 entry 装配与 re-export。
20
+ * workflow-hook.ts(唯一装配入口 + steer 文案)/ loop-gate.ts(合一状态机 +
21
+ * 错误签名归一化 + terminal 副作用链)。本文件仅剩 entry 装配与 re-export。
28
22
  */
29
23
 
30
24
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
25
 
32
- import { setupLoopGate, LoopGate } from "./loop-gate.js";
26
+ import { WorkflowGate } from "./loop-gate.js";
33
27
  import { executeStructuredOutput } from "./execute.js";
34
28
  import {
35
29
  createDailyToolDefinition,
@@ -37,7 +31,7 @@ import {
37
31
  ENV_SCHEMA,
38
32
  SO_SCHEMA_SIZE_WARN_BYTES,
39
33
  } from "./tool-definition.js";
40
- import { RetryState, setupWorkflowHook } from "./workflow-hook.js";
34
+ import { setupWorkflowHook } from "./workflow-hook.js";
41
35
 
42
36
  /** Pi Extension API — properly typed via ExtensionAPI from pi-coding-agent SDK */
43
37
  type PiAPI = ExtensionAPI;
@@ -49,9 +43,7 @@ export {
49
43
  createWorkflowToolDefinition,
50
44
  ENV_SCHEMA,
51
45
  SO_SCHEMA_SIZE_WARN_BYTES,
52
- RetryState,
53
- setupLoopGate,
54
- LoopGate,
46
+ WorkflowGate,
55
47
  };
56
48
 
57
49
  // ── Extension entry ────────────────────────────────────────────
@@ -60,13 +52,11 @@ export default function structuredOutputExtension(pi: PiAPI): void {
60
52
  const schemaEnv = process.env[ENV_SCHEMA];
61
53
 
62
54
  if (schemaEnv) {
63
- // ── Workflow 模式:单参数工具(注册期 fail-fast 防御)+ 强制调用 hook + 失败闸门 ──
55
+ // ── Workflow 模式:单参数工具(注册期 fail-fast 防御)+ 双闸门合一 hook ──
56
+ // D2:软 steer 与硬杀由同一 WorkflowGate 状态机承载(单状态机单 listener),
57
+ // terminal 单字段单写,硬杀不需要向 steer 侧做任何回调同步。
64
58
  pi.registerTool(createWorkflowToolDefinition(schemaEnv));
65
- const hookState = setupWorkflowHook(pi, schemaEnv);
66
- // D3/U2:闸门 terminal(同签名失败 ×3)→ 标记 hook 状态停 steer,写日志后
67
- // ctx.shutdown() 优雅终止(RPC mode 于 agent_settled 后 exit)。硬闸门在软
68
- // steer 之上兜底:G2 与模型配合度无关。
69
- setupLoopGate(pi, { onTerminal: () => hookState.markTerminal() });
59
+ setupWorkflowHook(pi, schemaEnv);
70
60
  } else {
71
61
  // ── 日常模式:双参数自报形态(行为不变,G4)──
72
62
  pi.registerTool(createDailyToolDefinition());
package/src/loop-gate.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  /**
2
- * 有界失败闸门(D3,U2 / M2)——G2「失败必有界」的执行体。
2
+ * 有界失败闸门 + steer 记账——双闸门合一状态机(D2 单状态机)+ G2「失败必有界」执行体。
3
+ *
4
+ * 状态归属(D2 单状态机):软 steer(workflow-hook 的重试提醒)与硬杀(本文件的
5
+ * 同签名失败 terminal)共用 WorkflowGate 一份状态——terminal 单字段单写,turn_end
6
+ * 守卫链与硬杀幂等短路读同一份事实;若拆成两个状态机,terminal 双写就需要跨对象
7
+ * 回调同步,漏接线即 steer 与硬杀失步(单状态机使失步在结构上不可能)。
3
8
  *
4
9
  * 通道选型(设计 §6.3;pi 0.84.1 实装,登记 PS-20):
5
10
  * 参数层失败(事故主形态)在 pi-ai validateToolArguments 抛错(validation.js:272-273)、
@@ -8,8 +13,9 @@
8
13
  * 的计数通道是 tool_execution_end 事件(sequential/parallel 两路径的 immediate
9
14
  * 分支均 emitToolExecutionEnd:agent-loop.js:277/:318/:348/:358)。
10
15
  *
11
- * 状态机:
12
- * - 只统计 name=structured-output 的 isError 事件;其他工具的成功/失败均忽略。
16
+ * 硬杀侧转移(onToolExecEnd 的 gate 段):
17
+ * - 只统计 name=structured-output 的 isError 事件;其他工具的成功/失败均忽略
18
+ * (toolName 过滤在装配层,见 workflow-hook.setupWorkflowHook)。
13
19
  * - 错误签名归一化(审查项#7 签名哈希化):截掉 "Received arguments:" 起的实参
14
20
  * 回显后,提取错误字段/路径 token 集合排序哈希为签名——集合不变 = 模型无进展
15
21
  * (重复同一批字段错误),集合变化(含缩小 = 渐进修复)= 模型在推进。旧 500c
@@ -29,12 +35,21 @@
29
35
  * 写日志(stderr + appendEntry 双通道,含 §5.2 形态 b 恢复指引)后
30
36
  * ctx.abort()(停当前 turn,截断 token 燃烧窗口)+ ctx.shutdown() 优雅终止
31
37
  * 子进程(RPC mode 在 agent_settled 后 exit),并武装 15s 兜底硬退 timer
32
- * (R3 F-2 bounded teardown,覆盖 pi 挂死不 settle 的异常态)。
38
+ * (R3 F-2 bounded teardown,覆盖 pi 挂死不 settle 的异常态;副作用序列见
39
+ * runTerminalTeardown,装配层在 newlyTerminal 时调用)。
33
40
  * - 成功调用清零(模型走通即无循环)。
34
41
  *
35
- * 与 workflow-hook 的关系:terminal 态经 onTerminal 回调标记 RetryState.terminal,
36
- * turn_end hook 据此不再 steer(防御性保留——shutdown 正常生效时进程已终止,
37
- * 该分支是 shutdown 失败路径下的保险)。
42
+ * steer 侧转移(onToolExecEnd 的记账段 + onTurnEnd,转移表锁定于 retry-state.test.ts):
43
+ * - 失败:soCallCount++ / lastSchemaError 记录(turn_end steer 的回灌文案原料)。
44
+ * - 成功:soSucceededEver=true(终态,后续 turn_end 不再干预)。
45
+ * - onTurnEnd 仅重置 steer 侧(soCallCount=0 / hookRetryCount++ / lastSchemaError=null),
46
+ * **不触碰硬杀侧连续失败计数**——跨 turn 的同签名计数连续性是「3 轮各失败 1 次
47
+ * 仍会 terminal」的等价前提(characterization-hook ③ 锁定)。
48
+ *
49
+ * 与 workflow-hook 的关系:本文件导出状态机与 terminal 副作用链(runTerminalTeardown),
50
+ * 装配唯一入口在 workflow-hook.setupWorkflowHook(单状态机单 listener:每个事件至多
51
+ * 一个 listener,同一 tool_execution_end 内先 steer 记账后硬杀计数——与合一前两
52
+ * listener 的注册顺序一致)。
38
53
  *
39
54
  * 诚实边界(设计 §6.3):闸门只终止「同签名无进展」这一种循环形态;签名不断
40
55
  * 变化的长尾低效不触发闸门,仍由 workflow 层 maxTurns 兜底。
@@ -43,21 +58,13 @@
43
58
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
44
59
  import { guardStaleCtx, isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
45
60
 
46
- import { isToolExecutionEndEvent } from "./schema-guards.js";
47
- import {
48
- extractToolErrorText,
49
- SIGNATURE_MAX_CHARS,
50
- truncateText,
51
- } from "./text-primitives.js";
61
+ import { SIGNATURE_MAX_CHARS, truncateText } from "./text-primitives.js";
52
62
 
53
63
  type PiAPI = ExtensionAPI;
54
64
 
55
- // 依赖方向说明:本模块 → text-primitives(共享叶节点,导出复用勿复制)。原先经
56
- // workflow-hook 取 extractToolErrorText 构成的模块环已破除——双方互引的纯函数/常量
57
- // 下沉到 text-primitives,依赖图单向(loop-gate → text-primitives ← workflow-hook)。
58
-
59
- /** 与 tool-definition.ts 的 TOOL_NAME 对应。 */
60
- const TOOL_NAME = "structured-output";
65
+ // 依赖方向说明:本模块 → text-primitives(共享叶节点,导出复用勿复制),依赖图单向
66
+ //(workflow-hook → loop-gate → text-primitives;D2 后装配层 workflow-hook 消费本模块
67
+ // 的状态机与 terminal 副作用链,本模块不反向依赖装配层)。
61
68
 
62
69
  /** 连续同签名失败阈值(设计 §6.3:对齐 qwen-code 的 3;workflow 子进程单用途短会话,更快失败更省)。 */
63
70
  export const MAX_CONSECUTIVE_FAILURES = 3;
@@ -337,26 +344,65 @@ export function normalizeErrorSignature(errorText: string): string {
337
344
  }
338
345
 
339
346
  /**
340
- * structured-output 失败循环闸门状态机(纯逻辑,可单测)。
347
+ * structured-output 双闸门合一状态机(D2 单状态机;纯逻辑,可单测)。
341
348
  *
342
- * 转移表(loop-gate.test.ts 锁定):
343
- * - onToolExecEnd(false) → 计数/签名清零(成功短路)
344
- * - onToolExecEnd(true, text) → 签名同:计数++;签名异:计数=1(新一轮连续从此失败起算)
345
- * - 计数达 MAX_CONSECUTIVE_FAILURES → terminal=true(不可逆;此后任何输入不再变化)
349
+ * 状态分区(一个对象、一份 terminal 事实):
350
+ * - steer 侧(workflow-hook turn_end 消费):soCallCount / soSucceededEver /
351
+ * hookRetryCount / lastSchemaError。
352
+ * - 硬杀侧:lastSignature / consecutiveFailures / lastErrorText。
353
+ * - 共享终态:terminal——硬杀触发后置位(不可逆,仅 reset() 可清),turn_end
354
+ * 守卫链据此停 steer,硬杀计数据此幂等短路。
355
+ *
356
+ * 转移表(loop-gate.test.ts / retry-state.test.ts 锁定):
357
+ * onToolExecEnd(hasError, errorText?)(每事件一次,先记账后计数——与合一前两
358
+ * listener 的注册顺序一致):
359
+ * - steer 记账段(不受 terminal 短路影响:terminal 后迟到的失败仍记录,闸门自身
360
+ * 幂等,重复事件无害):
361
+ * - (!hasError) → soCallCount++ / soSucceededEver=true
362
+ * - (hasError, errorText) → soCallCount++ / lastSchemaError=errorText ?? 通用提示
363
+ * - 硬杀计数段(terminal 后幂等短路,任何输入不再变化):
364
+ * - onToolExecEnd(false) → 计数/签名清零(成功短路)
365
+ * - onToolExecEnd(true) → 签名同:计数++;签名异:计数=1(新一轮连续从此失败起算)
366
+ * - 计数达 MAX_CONSECUTIVE_FAILURES → terminal=true(返回 newlyTerminal=true,
367
+ * 装配层据此触发 runTerminalTeardown)
368
+ * onTurnEnd()(仅当 turn_end 判定要 steer 且发送成功时由装配层调用):
369
+ * → soCallCount=0 / hookRetryCount++ / lastSchemaError=null;硬杀侧字段一律不动
370
+ * (跨 turn 同签名计数连续性,见文件头注释 steer 侧转移段)。
346
371
  */
347
- export class LoopGate {
348
- terminal = false;
372
+ export class WorkflowGate {
373
+ // ── steer 侧 ──────────────────────────────────────────────
374
+ soCallCount = 0;
375
+ soSucceededEver = false;
376
+ hookRetryCount = 0;
377
+ lastSchemaError: string | null = null;
378
+ // ── 硬杀侧 ────────────────────────────────────────────────
349
379
  private lastSignature: string | null = null;
350
380
  consecutiveFailures = 0;
351
381
  /** 最近一次失败的原始终态文本(截断后),供 terminal 日志引用。 */
352
382
  lastErrorText: string | null = null;
383
+ // ── 共享终态(单字段单写:硬杀计数段置位,两闸门读同一份)──────
384
+ terminal = false;
353
385
 
354
- /** 记录一次 structured-output tool 执行结果。hasError = event.isError === true。 */
386
+ /**
387
+ * 记录一次 structured-output tool 执行结果。hasError = event.isError === true。
388
+ * 返回硬杀侧形态(装配层只消费 newlyTerminal);steer 决策不经返回值表达——
389
+ * turn_end 时刻由 shouldSkipSteer 按状态字段推导(tool_execution_end 时刻无
390
+ * 消费者,返回值携带只是死字段)。
391
+ */
355
392
  onToolExecEnd(hasError: boolean, errorText?: string): {
356
393
  terminal: boolean;
357
394
  /** 仅在「本次调用触发 terminal」时为 true(幂等:terminal 后恒 false)。 */
358
395
  newlyTerminal: boolean;
359
396
  } {
397
+ // steer 记账段(先于硬杀段:与合一前两 listener 的注册顺序一致;无 terminal 短路)
398
+ this.soCallCount++;
399
+ if (!hasError) {
400
+ this.soSucceededEver = true;
401
+ } else {
402
+ this.lastSchemaError = errorText ?? "structured-output call failed";
403
+ }
404
+
405
+ // 硬杀计数段(terminal 幂等短路在最前:置位后成功/失败都不再变化)
360
406
  if (this.terminal) return { terminal: true, newlyTerminal: false };
361
407
 
362
408
  if (!hasError) {
@@ -383,6 +429,28 @@ export class LoopGate {
383
429
  return { terminal: false, newlyTerminal: false };
384
430
  }
385
431
 
432
+ /**
433
+ * turn 收尾(仅当要 steer 且发送成功时调用):重置本 turn 计数、累计重试次数、
434
+ * 清空错误。硬杀侧字段不动——成功路径之外的连续失败计数跨 turn 连续。
435
+ */
436
+ onTurnEnd(): void {
437
+ this.soCallCount = 0;
438
+ this.hookRetryCount++;
439
+ this.lastSchemaError = null;
440
+ }
441
+
442
+ /** 全字段归零(当前无调用方;保留作状态机完整契约——含 D2 合一后的硬杀侧字段)。 */
443
+ reset(): void {
444
+ this.soCallCount = 0;
445
+ this.soSucceededEver = false;
446
+ this.hookRetryCount = 0;
447
+ this.lastSchemaError = null;
448
+ this.lastSignature = null;
449
+ this.consecutiveFailures = 0;
450
+ this.lastErrorText = null;
451
+ this.terminal = false;
452
+ }
453
+
386
454
  /** 当前归一化签名(字段集合哈希或 fallback 前缀;无失败史时 null;测试与日志用)。 */
387
455
  get signature(): string | null {
388
456
  return this.lastSignature;
@@ -462,7 +530,7 @@ export function armForceExitTeardown(): void {
462
530
  * - appendEntry:session JSONL 持久化记录(事后排查通道,不进 LLM 上下文)
463
531
  * 两者内容同源,指引文案逐字对齐设计 §5.2。
464
532
  */
465
- function writeTerminatedLog(pi: PiAPI, gate: LoopGate): void {
533
+ function writeTerminatedLog(pi: PiAPI, gate: WorkflowGate): void {
466
534
  const lastError = gate.lastErrorText ?? "(no error text)";
467
535
  const stderrLines = [
468
536
  `[structured-output gate] Terminated: the same validation error occurred ${MAX_CONSECUTIVE_FAILURES} times consecutively; shutting down this single-purpose workflow subprocess.`,
@@ -489,56 +557,36 @@ function writeTerminatedLog(pi: PiAPI, gate: LoopGate): void {
489
557
  }
490
558
  }
491
559
 
492
- export interface LoopGateOptions {
493
- /** terminal 触发时的回调(index.ts 用于标记 RetryState.terminal,hook 据此停 steer)。 */
494
- onTerminal?: () => void;
495
- }
496
-
497
560
  /**
498
- * 注册 tool_execution_end 闸门监听(仅 workflow 模式装配,见 index.ts)。
561
+ * terminal 触发时的副作用链(装配层在 gate.onToolExecEnd 返回 newlyTerminal 时调用,
562
+ * 见 workflow-hook.setupWorkflowHook)。
499
563
  *
500
- * terminal 触发时序(R3 F-2 bounded teardown):onTerminal 回调(同步,先标记
501
- * hook 状态)→ 写日志(stderr + appendEntry 双通道,R3 F-3)→ ctx.abort()(中止
502
- * 当前 agent 操作——截断「shutdown 请求后当前 turn 的 bash/read/流式继续跑、模型
503
- * 继续烧 token」的窗口,~25s 实测窗口在 abort 后即止)→ ctx.shutdown()(RPC mode
504
- * 置 shutdownRequested,agent_settled 后进程 exit(0),父进程走「子进程结束但未
505
- * 产出 structured-output」的既有失败路径)→ armForceExitTeardown()(15s 兜底硬退,
506
- * 覆盖 pi 挂死不 settle 的异常态;方案依据见该函数注释)。
564
+ * 时序(R3 F-2 bounded teardown):写日志(stderr + appendEntry 双通道,R3 F-3)→
565
+ * ctx.abort()(中止当前 agent 操作——截断「shutdown 请求后当前 turn 的 bash/read/
566
+ * 流式继续跑、模型继续烧 token」的窗口,~25s 实测窗口在 abort 后即止)→
567
+ * ctx.shutdown()(RPC mode 置 shutdownRequested,agent_settled 后进程 exit(0),
568
+ * 父进程走「子进程结束但未产出 structured-output」的既有失败路径)→
569
+ * armForceExitTeardown()(15s 兜底硬退,覆盖 pi 挂死不 settle 的异常态;方案依据
570
+ * 见该函数注释)。
507
571
  */
508
- export function setupLoopGate(pi: PiAPI, options: LoopGateOptions = {}): LoopGate {
509
- const gate = new LoopGate();
510
-
511
- pi.on("tool_execution_end", async (event: unknown, ctx: ExtensionContext) => {
512
- if (!isToolExecutionEndEvent(event)) return;
513
- if (event.toolName !== TOOL_NAME) return;
514
-
515
- const outcome = gate.onToolExecEnd(
516
- event.isError === true,
517
- extractToolErrorText(event.result),
518
- );
519
- if (!outcome.newlyTerminal) return;
520
-
521
- options.onTerminal?.();
522
- writeTerminatedLog(pi, gate);
523
- // stale ctx 防御(crash-resilience D1):abort/shutdown 均在 pi assertActive 面
524
- // (PS-30,runner.js createContext)——session 替换窗口触发 terminal 时无人接的
525
- // 同步 throw 会经 async handler 变 rejected Promise 杀 pi 进程。stale 静默跳过
526
- // 优雅退出(此时进程的存在意义已随 session 替换消失),armForceExitTeardown 的
527
- // 15s 硬退兜底保持武装——自清理语义不丢。非 stale 错误原样上抛(守卫不吞真实 bug)。
528
- guardStaleCtx(() => {
529
- ctx.abort();
530
- ctx.shutdown();
531
- }, {
532
- label: "structured-output:terminal-teardown",
533
- onStale: (error) => {
534
- process.stderr.write(
535
- `[structured-output gate] terminal teardown skipped (stale ctx, session replaced): `
536
- + `${toErrorMessage(error)}; force-exit timer stays armed.\n`,
537
- );
538
- },
539
- });
540
- armForceExitTeardown();
572
+ export function runTerminalTeardown(pi: PiAPI, gate: WorkflowGate, ctx: ExtensionContext): void {
573
+ writeTerminatedLog(pi, gate);
574
+ // stale ctx 防御(crash-resilience D1):abort/shutdown 均在 pi assertActive 面
575
+ // (PS-30,runner.js createContext)——session 替换窗口触发 terminal 时无人接的
576
+ // 同步 throw 会经 async handler 变 rejected Promise 杀 pi 进程。stale 静默跳过
577
+ // 优雅退出(此时进程的存在意义已随 session 替换消失),armForceExitTeardown 的
578
+ // 15s 硬退兜底保持武装——自清理语义不丢。非 stale 错误原样上抛(守卫不吞真实 bug)。
579
+ guardStaleCtx(() => {
580
+ ctx.abort();
581
+ ctx.shutdown();
582
+ }, {
583
+ label: "structured-output:terminal-teardown",
584
+ onStale: (error) => {
585
+ process.stderr.write(
586
+ `[structured-output gate] terminal teardown skipped (stale ctx, session replaced): `
587
+ + `${toErrorMessage(error)}; force-exit timer stays armed.\n`,
588
+ );
589
+ },
541
590
  });
542
-
543
- return gate;
591
+ armForceExitTeardown();
544
592
  }
@@ -11,7 +11,7 @@
11
11
 
12
12
  /**
13
13
  * 闸门签名侧截断上限:① normalizeErrorSignature fallback 分支(提取不到字段 token 的
14
- * 非校验类错误文本降级为裸前缀做等值比较,消费方 loop-gate);② LoopGate.lastErrorText
14
+ * 非校验类错误文本降级为裸前缀做等值比较,消费方 loop-gate);② WorkflowGate.lastErrorText
15
15
  * (terminal 日志引用,不进模型上下文,消费方 loop-gate)。与 steer 回灌错误块上限
16
16
  * (STEER_ERROR_MAX_CHARS)语义独立(F5 拆分:签名只做比较原料、可随实现演化;steer
17
17
  * 是模型可见文本预算),两上限不共享数值演化。
@@ -1,19 +1,28 @@
1
1
  /**
2
- * Workflow hook:turn_end 时检查模型是否成功调用 structured-output 工具。
3
- * 未成功时通过 pi.sendUserMessage({deliverAs:"steer"}) 注入 steering message 重试。
4
- * 最多重试 MAX_HOOK_RETRIES 次,防止无限循环。
2
+ * Workflow hook 装配:D2 单状态机单 listener 的唯一装配入口。
5
3
  *
6
- * RetryState:从旧 4 个 mutable 闭包(soCallCount/soSucceededEver/hookRetryCount/
7
- * lastSchemaError)显式化为类——per-turn reset 时机成为可单测的显式契约:
8
- * onTurnEnd() 仅在「判定要 steer」时调用(守卫链 toolUse/超上限/成功短路均不调)。
4
+ * 装配形态(workflow 模式,见 src/index.ts):
5
+ * - tool_execution_end:唯一 listener——喂 loop-gate 的 WorkflowGate(合一状态机:
6
+ * steer 记账段 + 硬杀计数段,先记账后计数),newlyTerminal 时触发硬杀副作用链
7
+ * (runTerminalTeardown:日志 → abort+shutdown → 15s 兜底硬退)。
8
+ * - turn_end:唯一 listener——模型未成功产出 structured-output 时以 steer 方式注入
9
+ * 重试提醒(软闸门),提示词不伪装用户气泡,对话流用户内容 100% 来自用户输入;
10
+ * 最多重试 MAX_HOOK_RETRIES 次,防止无限循环。
11
+ *
12
+ * 软硬闸门合一语义:terminal 是 WorkflowGate 上的单一事实——硬杀计数段置位后,
13
+ * turn_end 守卫链第 0 条据此停 steer;两闸门间无任何回调接线(terminal 若双写于
14
+ * 两个状态机,就必须跨对象回调同步,漏接线即 steer 与硬杀失步——单状态机单写使
15
+ * 失步在结构上不可能)。
16
+ *
17
+ * steer 文案/预算逻辑(buildSteerReminder / shouldSkipSteer / writeSteerFailedLog)
18
+ * 变化原因是提示词工程迭代,与硬杀计数(错误签名归一化)分文件维护。
9
19
  */
10
20
 
11
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
22
  import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
13
23
 
14
- // 截断原语与错误块预算来自 text-primitives(共享叶节点,导出复用勿复制)。原
15
- // 「反向依赖(环)」已破除:本模块不再 import loop-gate,依赖图单向
16
- // (loop-gate → text-primitives ← workflow-hook)。
24
+ import { WorkflowGate, runTerminalTeardown } from "./loop-gate.js";
25
+ // 截断原语与错误块预算来自 text-primitives(共享叶节点,导出复用勿复制)。
17
26
  import {
18
27
  extractToolErrorText,
19
28
  STEER_ERROR_MAX_CHARS,
@@ -27,99 +36,25 @@ import { isObjectRootSchema } from "./execute.js";
27
36
  type PiAPI = ExtensionAPI;
28
37
 
29
38
  // 与 tool-definition.ts 的 TOOL_NAME 对应(本模块只监听该工具的 execution 事件;
30
- // 保持依赖图单向:workflow-hook → schema-guards,不 import tool-definition)。
39
+ // 依赖图单向:workflow-hook → schema-guards / loop-gate / text-primitives,不成环)。
31
40
  const TOOL_NAME = "structured-output";
32
41
  const MAX_HOOK_RETRIES = 2;
33
42
 
34
- /**
35
- * structured-output 调用状态机(IF-7,export 契约——M5 从本模块 import)。
36
- *
37
- * 转移表(M4-TC-2 单测锁定):
38
- * - onToolExecEnd(false) → soCallCount++ / soSucceededEver=true(成功短路终态)
39
- * - onToolExecEnd(true, err) → soCallCount++ / lastSchemaError=err ?? 通用提示
40
- * - onTurnEnd() → soCallCount=0 / hookRetryCount++ / lastSchemaError=null
41
- * (仅当「判定要 steer 且发送成功」时调用——守卫链 toolUse/error/aborted/超上限/
42
- * 成功短路/发送失败均不调,故 toolUse 保留 soCallCount、超上限保留
43
- * lastSchemaError,与旧 4-closure 逐点一致)
44
- *
45
- * terminal 态(D3/U2):由 loop-gate 在同签名失败达 3 次时经 markTerminal() 置位,
46
- * turn_end hook 据此不再 steer——防御性保留:shutdown 正常生效时进程已终止,
47
- * 此分支是 shutdown 失败路径下的保险。terminal 不影响 onToolExecEnd 记录
48
- * (闸门自身幂等,重复事件无害)。
49
- */
50
- export class RetryState {
51
- soCallCount = 0;
52
- soSucceededEver = false;
53
- hookRetryCount = 0;
54
- lastSchemaError: string | null = null;
55
- terminal = false;
56
-
57
- /** 闸门 terminal 置位(loop-gate 经 index.ts 回调调用;不可逆,仅 reset() 可清)。 */
58
- markTerminal(): void {
59
- this.terminal = true;
60
- }
61
-
62
- /** 记录一次 structured-output tool 执行结果。hasError = event.isError === true。 */
63
- onToolExecEnd(hasError: boolean, errorMsg?: string): { shouldSteer: boolean } {
64
- this.soCallCount++;
65
- if (!hasError) {
66
- this.soSucceededEver = true;
67
- return { shouldSteer: false };
68
- }
69
- this.lastSchemaError = errorMsg ?? "structured-output call failed";
70
- return { shouldSteer: true };
71
- }
72
-
73
- /** turn 收尾(仅当要 steer 时调用):重置本 turn 计数、累计重试次数、清空错误。 */
74
- onTurnEnd(): void {
75
- this.soCallCount = 0;
76
- this.hookRetryCount++;
77
- this.lastSchemaError = null;
78
- }
79
-
80
- /** 五字段归零(当前无调用方;保留作状态机完整契约——含 U2 新增 terminal 态)。 */
81
- reset(): void {
82
- this.soCallCount = 0;
83
- this.soSucceededEver = false;
84
- this.hookRetryCount = 0;
85
- this.lastSchemaError = null;
86
- this.terminal = false;
87
- }
88
- }
89
-
90
43
  /** steer 发送失败告警的 appendEntry customType(session JSONL 持久化,不进 LLM 上下文)。 */
91
44
  export const HOOK_ENTRY_TYPE = "structured-output:hook";
92
45
 
93
46
  /**
94
- * steer 发送失败告警(审查项#8 失败路径):双通道落盘(同 loop-gate writeTerminatedLog
95
- * 惯例)——stderr 直出 + appendEntry 持久化。预算未扣减由「不调 onTurnEnd」结构保证,
96
- * 下一个正常收尾的轮仍会重试 steer,不产生静默哑火。
47
+ * steer reminder 的 sendMessage customType(role:"custom" 消息,pi convertToLlm 对其
48
+ * 无条件转 LLM user 消息——LLM 可见性与 user message 无差别;display:false 不渲染
49
+ * 用户气泡,对话流归属语义结构性正确。锚点登记 docs/pi-semantics.json PS-43:
50
+ * pi dist/core/messages.js:89-96 case "custom" 无条件 role:"user")。
97
51
  */
98
- function writeSteerFailedLog(pi: PiAPI, hookRetryCount: number, err: unknown): void {
99
- const message = toErrorMessage(err);
100
- process.stderr.write(
101
- `[structured-output hook] steer send failed (retry budget NOT consumed, will retry at next turn end): ${message}\n`,
102
- );
103
- try {
104
- pi.appendEntry(HOOK_ENTRY_TYPE, {
105
- event: "steer_send_failed",
106
- hookRetryCount,
107
- error: message,
108
- guidance:
109
- "The steering message could not be delivered (e.g. compaction in progress or extension deactivated); the retry budget was preserved and the hook will retry at the next turn end.",
110
- });
111
- } catch (appendErr) {
112
- // appendEntry 失败不阻断 hook——stderr 通道已落,此处补诊断(同 cache-probe 惯例)
113
- process.stderr.write(
114
- `[structured-output hook] appendEntry failed: ${toErrorMessage(appendErr)}\n`,
115
- );
116
- }
117
- }
52
+ export const RETRY_REMINDER_CUSTOM_TYPE = "structured-output:retry-reminder";
118
53
 
119
54
  /**
120
55
  * steer 守卫链(原 turn_end handler 内联守卫提取,判定顺序与旧实现逐条一致)。
121
56
  * 命中任一守卫即跳过 steer,且调用方不调 onTurnEnd(toolUse/error/aborted 保留
122
- * soCallCount、超上限保留 lastSchemaError,与旧 4-closure 逐点一致):
57
+ * soCallCount、超上限保留 lastSchemaError,与合一前逐点一致):
123
58
  * 0. 闸门 terminal(D3/U2):同签名失败已满 3 次、shutdown 已发——不再 steer,
124
59
  * 让进程终止(防御性保留:shutdown 正常生效时进程已死,到不了这里)
125
60
  * 1. 已经成功调用过 structured-output,不再干预
@@ -131,7 +66,7 @@ function writeSteerFailedLog(pi: PiAPI, hookRetryCount: number, err: unknown): v
131
66
  * chatMode 复用子进程时会泄漏成下一轮的陈旧指令——不发送
132
67
  * 5. 超过重试上限:放弃,让子进程自然结束(调用方据 result.error 判定失败)
133
68
  */
134
- function shouldSkipSteer(state: RetryState, event: unknown): boolean {
69
+ function shouldSkipSteer(state: WorkflowGate, event: unknown): boolean {
135
70
  if (state.terminal) return true;
136
71
  if (state.soSucceededEver) return true;
137
72
  if (!isTurnEndEvent(event)) return true;
@@ -193,40 +128,68 @@ function buildSteerReminder(
193
128
  }
194
129
 
195
130
  /**
196
- * 注册 turn_end hook,检查模型是否成功调用 structured-output 工具。
197
- * 未成功时通过 pi.sendUserMessage({deliverAs:"steer"}) 注入 steering message 重试。
198
- * 最多重试 MAX_HOOK_RETRIES 次,防止无限循环。
199
- *
200
- * @returns 共享的 RetryState(U2:index.ts 拿它接线 loop-gate 的 onTerminal 回调)。
131
+ * steer 发送失败告警(审查项#8 失败路径):双通道落盘(同 loop-gate writeTerminatedLog
132
+ * 惯例)——stderr 直出 + appendEntry 持久化。预算未扣减由「不调 onTurnEnd」结构保证,
133
+ * 下一个正常收尾的轮仍会重试 steer,不产生静默哑火。
134
+ */
135
+ function writeSteerFailedLog(pi: PiAPI, hookRetryCount: number, err: unknown): void {
136
+ const message = toErrorMessage(err);
137
+ process.stderr.write(
138
+ `[structured-output hook] steer send failed (retry budget NOT consumed, will retry at next turn end): ${message}\n`,
139
+ );
140
+ try {
141
+ pi.appendEntry(HOOK_ENTRY_TYPE, {
142
+ event: "steer_send_failed",
143
+ hookRetryCount,
144
+ error: message,
145
+ guidance:
146
+ "The steering message could not be delivered (e.g. compaction in progress or extension deactivated); the retry budget was preserved and the hook will retry at the next turn end.",
147
+ });
148
+ } catch (appendErr) {
149
+ // appendEntry 失败不阻断 hook——stderr 通道已落,此处补诊断(同 cache-probe 惯例)
150
+ process.stderr.write(
151
+ `[structured-output hook] appendEntry failed: ${toErrorMessage(appendErr)}\n`,
152
+ );
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 装配 workflow 模式的双闸门(D2 唯一装配入口,仅 workflow 模式调用,见 src/index.ts)。
201
158
  *
202
- * 两种失败形态都会触发 steer:
203
- * 1. 完全没调用(soCallCount === 0)→ 注入"必须调用"提示 + 正确 schema
204
- * 2. 调了但全是 isError(soCallCount > 0 && !soSucceededEver)→ 注入具体校验错误
205
- * + 正确 schema。旧实现在此处撒手交给 Pi 自然修正,但模型遇到 "Invalid JSON Schema"
206
- * 时无法自行修正(它不知道正确 schema 长什么样),实测会放弃 → 子进程正常退出 →
207
- * workflow 把单点失败放大成整批崩溃。故此处主动 steer 并回灌错误细节。
159
+ * 每个事件至多一个 listener(单 listener 契约,D2):
160
+ * - tool_execution_end:结构守卫 → toolName 过滤 → WorkflowGate.onToolExecEnd
161
+ * (合一状态机:先 steer 记账后硬杀计数)→ newlyTerminal 时 runTerminalTeardown
162
+ * (terminal 时序与副作用明细见该函数注释)。
163
+ * - turn_end:守卫链 shouldSkipSteer → 两种失败形态都 steer:
164
+ * 1. 完全没调用(soCallCount === 0)→ 注入"必须调用"提示 + 正确 schema
165
+ * 2. 调了但全是 isError(soCallCount > 0 && !soSucceededEver)→ 注入具体校验错误
166
+ * + 正确 schema。旧实现在此处撒手交给 Pi 自然修正,但模型遇到 "Invalid JSON Schema"
167
+ * 时无法自行修正(它不知道正确 schema 长什么样),实测会放弃 → 子进程正常退出 →
168
+ * workflow 把单点失败放大成整批崩溃。故此处主动 steer 并回灌错误细节。
208
169
  *
209
170
  * 检测时序:Pi 保证同 turn 内所有 tool_execution_end 都在 turn_end 之前触发,
210
171
  * 故 turn_end 读取的状态已反映本 turn 全部 tool 调用结果。
211
172
  */
212
- export function setupWorkflowHook(pi: PiAPI, schemaJson: string): RetryState {
213
- const state = new RetryState();
173
+ export function setupWorkflowHook(pi: PiAPI, schemaJson: string): void {
174
+ const state = new WorkflowGate();
214
175
 
215
176
  // 根类型判定与 {value} 包装判定同源(isObjectRootSchema,P6):注册期
216
177
  // createWorkflowToolDefinition 已对同一 schema 完成 assertJsonSchemaRoot fail-fast,
217
178
  // 此处解析失败不可能到达;真失败时判定为非 object 根 → 包装契约文案(保守方向)。
218
179
  const isObjectRoot = isObjectRootSchema(tryParseJson(schemaJson));
219
180
 
220
- // 追踪 structured-output 调用结果:
221
- // 成功 → soSucceededEver=true(终态,后续不再干预)
222
- // 失败 → soCallCount++,记录 lastSchemaError,由 turn_end 决定是否 steer 重试
223
- pi.on("tool_execution_end", async (event: unknown) => {
181
+ pi.on("tool_execution_end", async (event: unknown, ctx: ExtensionContext) => {
224
182
  if (!isToolExecutionEndEvent(event)) return;
225
183
  if (event.toolName !== TOOL_NAME) return;
226
- state.onToolExecEnd(
184
+
185
+ // errorText 不在此处 fallback——合一状态机记账段/计数段对 undefined 同用
186
+ // "structured-output call failed" 通用提示(与合一前两 listener 传入形态等价)。
187
+ const outcome = state.onToolExecEnd(
227
188
  event.isError === true,
228
- extractToolErrorText(event.result) ?? "structured-output call failed",
189
+ extractToolErrorText(event.result),
229
190
  );
191
+ if (!outcome.newlyTerminal) return;
192
+ runTerminalTeardown(pi, state, ctx);
230
193
  });
231
194
 
232
195
  pi.on("turn_end", async (event: unknown) => {
@@ -240,14 +203,18 @@ export function setupWorkflowHook(pi: PiAPI, schemaJson: string): RetryState {
240
203
  // 故 onTurnEnd()(清空 lastSchemaError)必须在发送成功之后调用。
241
204
  const reminder = buildSteerReminder(calledButFailed, state.lastSchemaError, schemaJson, isObjectRoot);
242
205
 
243
- // 审查项#8:await 发送结果——发送失败(如 compaction 中 prompt() 抛错 / 扩展已
206
+ // 审查项#8:await 发送结果——发送失败(如 compaction 中开轮抛错 / 扩展已
244
207
  // 被 assertActive 拒绝)不扣减重试预算(不调 onTurnEnd),否则 fire-and-forget
245
208
  // 丢一份 steer + 白扣一次预算,两次即永久哑火。
246
- // pi 0.84.1 实装(loader.js):extension 侧 sendUserMessage 同步转发且吞掉异步
247
- // rejection(转 emitError)返回 void——await 对 undefined 立即解析;此处的
248
- // try/catch 兜住同步 throw(assertActive)与未来 pi 返回真 Promise 的形态。
209
+ // pi 0.84.4 实装(loader.js):extension 侧 sendMessage 同步转发且吞掉异步
210
+ // rejection(bindCore .catch(emitError) 转事件)返回 void——await 对 undefined
211
+ // 立即解析;此处的 try/catch 兜住同步 throw(assertActive)与未来 pi 返回真
212
+ // Promise 的形态。
249
213
  try {
250
- await pi.sendUserMessage(reminder, { deliverAs: "steer" });
214
+ await pi.sendMessage(
215
+ { customType: RETRY_REMINDER_CUSTOM_TYPE, content: reminder, display: false },
216
+ { deliverAs: "steer", triggerTurn: true },
217
+ );
251
218
  } catch (err) {
252
219
  writeSteerFailedLog(pi, state.hookRetryCount, err);
253
220
  return;
@@ -255,7 +222,4 @@ export function setupWorkflowHook(pi: PiAPI, schemaJson: string): RetryState {
255
222
  // 发送成功才按本 turn 重置计数、累计重试次数、清空 lastSchemaError
256
223
  state.onTurnEnd();
257
224
  });
258
-
259
- // U2:暴露共享状态——index.ts 接线 loop-gate 的 onTerminal 回调(markTerminal)
260
- return state;
261
225
  }