@zhushanwen/pi-structured-output 5.0.2 → 5.1.1
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 +2 -2
- package/src/execute.ts +63 -114
- package/src/index.ts +43 -11
- package/src/loop-gate.ts +567 -0
- package/src/text-primitives.ts +66 -0
- package/src/tool-definition.ts +182 -19
- package/src/workflow-hook.ts +157 -57
package/src/tool-definition.ts
CHANGED
|
@@ -1,22 +1,191 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* structured-output tool
|
|
2
|
+
* structured-output tool 定义(D1 双变体分岔)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* - createWorkflowToolDefinition(envSchema):PI_WORKFLOW_SCHEMA 存在时注册。
|
|
5
|
+
* parameters 即权威 schema 本身(object 根直接用 + 根级 additionalProperties
|
|
6
|
+
* 未声明时注入 false(D4);非 object 根包装 {value}(P6:tool call arguments
|
|
7
|
+
* 必须是 object,execute 侧对称解包))。「模型不携带 schema」从文案约束升级为
|
|
8
|
+
* 结构约束——pi-ai 参数层直接按权威 schema 校验,不存在可以传错的地方(G1/G3)。
|
|
9
|
+
* description 按注册期已知的根类型条件化:object 根口径「arguments ARE the
|
|
10
|
+
* data」;非 object 根参数层实际是 {value} 包装(模型直传裸值必首调失败),
|
|
11
|
+
* 文案明确告知包装契约与 value. 错误路径前缀。裸 object(D4 后只接受空对象)
|
|
12
|
+
* 追加显式警示。文案判定与包装判定同源(isObjectRootSchema),无漂移。
|
|
13
|
+
* 注册期 fail-fast 防御:keyword-less 拒绝(原 ERR-3)与 boolean true 拦截
|
|
14
|
+
* (原 ERR-7)从 execute 权威分支上移,非法 schema 在子进程加载期终止。
|
|
15
|
+
* - createDailyToolDefinition():无 env 时注册。双参数自报形态逐字节保留(G4),
|
|
16
|
+
* 仅移除描述文本中的 workflow 语句(D5:workflow 语义只属于 workflow 变体)。
|
|
17
|
+
*
|
|
18
|
+
* 两变体的描述文本均被 prompt-quality.test.ts 文本断言锁定。
|
|
8
19
|
*/
|
|
9
20
|
|
|
10
21
|
import { Type } from "typebox";
|
|
11
22
|
|
|
12
|
-
import { executeStructuredOutput } from "./execute.js";
|
|
23
|
+
import { executeStructuredOutput, isObjectRootSchema } from "./execute.js";
|
|
24
|
+
import {
|
|
25
|
+
assertJsonSchemaRoot,
|
|
26
|
+
echo,
|
|
27
|
+
hasSchemaKeyword,
|
|
28
|
+
isPlainObject,
|
|
29
|
+
tryParseJson,
|
|
30
|
+
} from "./schema-guards.js";
|
|
13
31
|
|
|
14
32
|
export const TOOL_NAME = "structured-output";
|
|
15
33
|
export const ENV_SCHEMA = "PI_WORKFLOW_SCHEMA";
|
|
16
34
|
|
|
17
|
-
|
|
35
|
+
/**
|
|
36
|
+
* schema env 值的可见性提示阈值(256 KiB,SO-DATA-4)。
|
|
37
|
+
*
|
|
38
|
+
* 背景:PI_WORKFLOW_SCHEMA 经 spawn childEnv 注入子进程,env 块受 ARG_MAX 约束
|
|
39
|
+
*(Linux E2BIG)——超大 schema 会在 spawn 调用点报难归因的失败。硬拒绝在
|
|
40
|
+
* subagent-workflow 侧(session-runner 的 applySchemaEnvToChildEnv,同值上限
|
|
41
|
+
* SCHEMA_ENV_MAX_BYTES = 256 * 1024,见其 src/shared/schema-env.ts [跨包契约 SSOT]
|
|
42
|
+
* 注释);本包独立 npm 不能直接 import(isObjectRootSchema 本地副本同例),常量
|
|
43
|
+
* 各自保留、跨包契约测试锁字节相等(tests/cross-package-contract.test.ts)。
|
|
44
|
+
* 本侧职责仅可见性:注册时超限 logger.warn(无 logger API,stderr 直出惯例)提示
|
|
45
|
+
* env 通道有上限,建议拆分 schema 或精简——不拒绝注册(子进程能收到 env 说明 SW
|
|
46
|
+
* 侧闸门已放行,此处拒绝只会把可诊断的降级变成无法启动)。
|
|
47
|
+
*
|
|
48
|
+
* [跨包契约] 任一端改值必须同步另一端,否则 SW 侧硬拒绝线与 SO 侧提示线漂移。
|
|
49
|
+
*/
|
|
50
|
+
const SCHEMA_SIZE_WARN_KIB = 256;
|
|
51
|
+
const BYTES_PER_KIB = 1024;
|
|
52
|
+
export const SO_SCHEMA_SIZE_WARN_BYTES = SCHEMA_SIZE_WARN_KIB * BYTES_PER_KIB;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 注册期 schema 体积可见性提示(SO-DATA-4 的 SO 侧职责:提示,不拒绝)。
|
|
56
|
+
* stderr 直出(本包惯例:ExtensionContext 无 logger 成员,pi 0.84.1 types.d.ts
|
|
57
|
+
* 核实;与 writeTerminatedLog 同通道)。
|
|
58
|
+
*/
|
|
59
|
+
function warnIfSchemaOversized(envSchema: string): void {
|
|
60
|
+
const bytes = Buffer.byteLength(envSchema, "utf8");
|
|
61
|
+
if (bytes <= SO_SCHEMA_SIZE_WARN_BYTES) return;
|
|
62
|
+
process.stderr.write(
|
|
63
|
+
`[structured-output] PI_WORKFLOW_SCHEMA is ${bytes} bytes (> ${SO_SCHEMA_SIZE_WARN_BYTES} bytes / `
|
|
64
|
+
+ `${SO_SCHEMA_SIZE_WARN_BYTES / BYTES_PER_KIB} KiB). The env channel has a size ceiling: the workflow runner `
|
|
65
|
+
+ "rejects injection above its own limit (spawn fails, hard to attribute), and oversized values can "
|
|
66
|
+
+ "hit E2BIG (ARG_MAX) at spawn. "
|
|
67
|
+
+ "👉 精简 outputSchema(删冗余 description / 收敛深嵌套)或拆分为多个小 schema 步骤。\n",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Workflow variant (parameters = authoritative schema) ─────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 合成 workflow 单参数工具(D1)。
|
|
75
|
+
*
|
|
76
|
+
* @param envSchema PI_WORKFLOW_SCHEMA env 原值(JSON 字符串;tryParseJson 兼容已解析对象)。
|
|
77
|
+
* @throws 权威 schema 非法(非 object/boolean 根、boolean true、keyword-less object)
|
|
78
|
+
* 时在注册期 fail-fast——错误指回 workflow 脚本的 schema 定义(§5.2 形态 d)。
|
|
79
|
+
*/
|
|
80
|
+
export function createWorkflowToolDefinition(envSchema: string) {
|
|
81
|
+
// SO-DATA-4:注册期体积可见性提示(超 256KiB 提示精简/拆分;硬拒绝在 SW 侧注入点)
|
|
82
|
+
warnIfSchemaOversized(envSchema);
|
|
83
|
+
|
|
84
|
+
// ── 注册期 fail-fast 防御(上移自 execute 权威分支)──
|
|
85
|
+
const schema = tryParseJson(envSchema);
|
|
86
|
+
assertJsonSchemaRoot(schema);
|
|
87
|
+
|
|
88
|
+
if (schema === true) {
|
|
89
|
+
// ERR-7 上移:boolean true(accept-all)不提供任何形状约束,workflow 用它等于没校验。
|
|
90
|
+
throw new Error(
|
|
91
|
+
"Authoritative schema (PI_WORKFLOW_SCHEMA) is boolean true (accept-all), "
|
|
92
|
+
+ "provides no shape constraint. 👉 改为带 type/properties/items 的 object schema。",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isPlainObject(schema) && !hasSchemaKeyword(schema)) {
|
|
97
|
+
// ERR-3 上移:keyword-less 对象会被编译成 accept-all,workflow 声明的约束静默失效。
|
|
98
|
+
throw new Error(
|
|
99
|
+
"Authoritative schema (PI_WORKFLOW_SCHEMA) has no recognized keyword. "
|
|
100
|
+
+ "A workflow schema must describe shape via type/properties/items/... "
|
|
101
|
+
+ "👉 检查 workflow 脚本的 outputSchema 定义,补全 JSON Schema 关键字。 "
|
|
102
|
+
+ `Received schema=${echo(schema)}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const isObjectRoot = isObjectRootSchema(schema);
|
|
107
|
+
const isBareObjectRoot = isBareObjectRootSchema(schema);
|
|
108
|
+
|
|
109
|
+
// D4:根级 additionalProperties 未声明时注入 false;作者显式声明(true / 子 schema)
|
|
110
|
+
// 尊重不动。堵输出污染:旧双参数 envelope 把模型习惯携带的 schema 隔离在专用参数里,
|
|
111
|
+
// 单参数后该习惯会直接混进 arguments(事故证明 deepseek 类模型有强烈携带倾向),
|
|
112
|
+
// 参数层显式拒绝并让模型自修正,优于静默剥离。嵌套层级宽严完全由作者 schema 自治。
|
|
113
|
+
// P6:非 object 根(array / string / boolean / 组合根等)包一层 {value}——tool call
|
|
114
|
+
// arguments 协议上必须是 object。包装/解包判定与 execute 同源(isObjectRootSchema)。
|
|
115
|
+
const parameters = isObjectRoot
|
|
116
|
+
? Type.Unsafe<Record<string, unknown>>({
|
|
117
|
+
...schema,
|
|
118
|
+
// type 数组根(如 ["object","null"])收敛为字符串 "object":顶层 type 序列化为
|
|
119
|
+
// 数组会被严格 OpenAI 兼容网关按 C-ext-03 立约动机整会话 400;且 arguments
|
|
120
|
+
// 协议上恒为 object,"null" 成员本就不可达,含 object 成员时收敛语义无损。
|
|
121
|
+
...(Array.isArray(schema.type) && schema.type.includes("object") ? { type: "object" } : {}),
|
|
122
|
+
additionalProperties: schema.additionalProperties ?? false,
|
|
123
|
+
})
|
|
124
|
+
: Type.Unsafe<{ value: unknown }>({
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: { value: schema },
|
|
127
|
+
required: ["value"],
|
|
128
|
+
additionalProperties: false,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
name: TOOL_NAME,
|
|
133
|
+
label: "Structured Output",
|
|
134
|
+
// 根类型条件化(与上方 parameters 包装判定同一 isObjectRootSchema,天然同源)。
|
|
135
|
+
// 保持内联 ternary + 公共尾部:prompt-quality.test.ts 按源码拼接文本断言,
|
|
136
|
+
// object 根口径字面量被逐字锁定,抽出辅助函数会破坏提取与锁定。
|
|
137
|
+
description:
|
|
138
|
+
(isObjectRoot
|
|
139
|
+
// object 根:arguments 即 data。
|
|
140
|
+
? "Return the structured result for this task. Your arguments ARE the data; "
|
|
141
|
+
+ "they are validated against this schema — this tool's parameter schema IS the required shape of your result."
|
|
142
|
+
// 非 object 根:参数层实际是 {value} 包装(P6),直传裸值必首调失败,
|
|
143
|
+
// 必须显式告知包装契约;value. 错误路径前缀一并在首读时说明(参数层
|
|
144
|
+
// 错误文案无改写通道,指引只能前置携带)。
|
|
145
|
+
: "Return the structured result for this task. Your single argument must be an object "
|
|
146
|
+
+ "`{value: <data>}` — put the result itself in `value`, and it must conform to this schema. "
|
|
147
|
+
+ "Non-object schemas are wrapped in a `value` field because tool call arguments must be objects. "
|
|
148
|
+
+ "Validation errors may reference paths starting with `value.` (e.g. `value.0`, `value.name`): "
|
|
149
|
+
+ "that prefix addresses the wrapper, not your data — strip it to locate the offending field.")
|
|
150
|
+
// 裸 object:D4 注入后 parameters 只接受空对象,首读即警示,避免模型
|
|
151
|
+
// 携带字段反复撞校验。
|
|
152
|
+
+ (isBareObjectRoot
|
|
153
|
+
? "\n\nNote: this schema accepts only an empty object {}; any fields will be rejected."
|
|
154
|
+
: "")
|
|
155
|
+
+ "\n\nDo not output the result as text — call this tool instead.\n"
|
|
156
|
+
+ "If validation fails, the error names the fields that failed: "
|
|
157
|
+
+ "fix those fields to match this tool's parameter schema and call the tool again.",
|
|
158
|
+
parameters,
|
|
159
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
160
|
+
// D2 透传:pi-ai 参数层已按上面的 parameters(= 权威 schema)校验 + 类型矫正过
|
|
161
|
+
// arguments,execute 不做第二校验(第二校验权威是方案 A 明令禁止的形态)。
|
|
162
|
+
// 非 object 根的 {value} 解包由 executeStructuredOutput 的 workflow 分支按
|
|
163
|
+
// 「根是否 object」对称完成(与注册期包装判定同一函数 isObjectRootSchema)。
|
|
164
|
+
return executeStructuredOutput({ data: params, authoritativeSchema: schema });
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 裸 object 根检测:object 形态但无任何属性约束(无 properties/patternProperties/
|
|
171
|
+
* required/minProperties/maxProperties,且未显式声明 additionalProperties)。该形态经
|
|
172
|
+
* D4 注入 false 后 parameters 只接受空对象 {}。注册期静态可判定 → description 显式警示。
|
|
173
|
+
* minProperties/maxProperties 也构成约束(F2):{type:object,minProperties:1} 连空对象
|
|
174
|
+
* 都拒绝,警示「只接受空对象」反而误导——同 required 一样交由参数层校验错误自然暴露。
|
|
175
|
+
*/
|
|
176
|
+
function isBareObjectRootSchema(schema: unknown): boolean {
|
|
177
|
+
if (!isObjectRootSchema(schema)) return false;
|
|
178
|
+
return !("properties" in schema)
|
|
179
|
+
&& !("patternProperties" in schema)
|
|
180
|
+
&& !("required" in schema)
|
|
181
|
+
&& !("minProperties" in schema)
|
|
182
|
+
&& !("maxProperties" in schema)
|
|
183
|
+
&& !("additionalProperties" in schema);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Daily variant (self-reported {schema, data}, unchanged behavior) ──
|
|
18
187
|
|
|
19
|
-
export function
|
|
188
|
+
export function createDailyToolDefinition() {
|
|
20
189
|
return {
|
|
21
190
|
name: TOOL_NAME,
|
|
22
191
|
label: "Structured Output",
|
|
@@ -24,8 +193,6 @@ export function createToolDefinition() {
|
|
|
24
193
|
"Return structured output validated against a JSON Schema. "
|
|
25
194
|
+ "Call this tool to produce validated JSON data. "
|
|
26
195
|
+ "Pass `schema` (a JSON Schema draft-07 object) and `data` (the value to validate). "
|
|
27
|
-
+ "When the schema is system-enforced (workflow mode), pass ONLY `data` — "
|
|
28
|
-
+ "the `schema` parameter is ignored (the system validates `data` against the authoritative schema).\n\n"
|
|
29
196
|
+ "schema describes the shape; data fills the values; they must match.\n\n"
|
|
30
197
|
+ "✅ Correct (full call): structured_output({schema:{type:'object',properties:{name:{type:'string'},age:{type:'number'}},required:['name']}, data:{name:'Alice',age:30}})\n"
|
|
31
198
|
+ "✅ Correct: schema={type:'array',items:{type:'string'}}, data=['a','b','c']\n"
|
|
@@ -60,16 +227,12 @@ export function createToolDefinition() {
|
|
|
60
227
|
_toolCallId: string,
|
|
61
228
|
params: { schema: unknown; data: unknown },
|
|
62
229
|
) {
|
|
63
|
-
// workflow 模式(PI_WORKFLOW_SCHEMA 存在):权威 schema 成为唯一校验权威,
|
|
64
|
-
// LLM 传入的 params.schema 被降级为错误回显,无法影响校验结果。
|
|
65
|
-
//
|
|
66
230
|
// 运行假设:workflow 子进程是单 session 进程(由 applySchemaEnvToChildEnv 在
|
|
67
|
-
// session-runner 注入 PI_WORKFLOW_SCHEMA
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// 和 applySchemaEnvToChildEnv 的 `if (schemaEnv)` 统一:空串 env 视为未设置。
|
|
231
|
+
// session-runner 注入 PI_WORKFLOW_SCHEMA)。装配分岔下日常变体注册时 env 为空,
|
|
232
|
+
// 但桥接判定保留(ENV_SCHEMA 存在 = workflow 模式):env 有值时注入
|
|
233
|
+
// authoritativeSchema 走 execute 的 workflow 透传分支。
|
|
234
|
+
// 判空用 `|| undefined` 归一空串为 undefined(truthy 语义),与 entry 的
|
|
235
|
+
// `if (schemaEnv)` 统一:空串 env 视为未设置。
|
|
73
236
|
const authoritativeSchema = process.env[ENV_SCHEMA] || undefined;
|
|
74
237
|
return executeStructuredOutput(
|
|
75
238
|
authoritativeSchema !== undefined
|
package/src/workflow-hook.ts
CHANGED
|
@@ -10,7 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// 截断原语与错误块预算来自 text-primitives(共享叶节点,导出复用勿复制)。原
|
|
14
|
+
// 「反向依赖(环)」已破除:本模块不再 import loop-gate,依赖图单向
|
|
15
|
+
// (loop-gate → text-primitives ← workflow-hook)。
|
|
16
|
+
import {
|
|
17
|
+
extractToolErrorText,
|
|
18
|
+
STEER_ERROR_MAX_CHARS,
|
|
19
|
+
truncateText,
|
|
20
|
+
} from "./text-primitives.js";
|
|
21
|
+
|
|
22
|
+
import { isToolExecutionEndEvent, isTurnEndEvent, tryParseJson } from "./schema-guards.js";
|
|
23
|
+
import { isObjectRootSchema } from "./execute.js";
|
|
14
24
|
|
|
15
25
|
/** Pi Extension API — properly typed via ExtensionAPI from pi-coding-agent SDK */
|
|
16
26
|
type PiAPI = ExtensionAPI;
|
|
@@ -27,14 +37,26 @@ const MAX_HOOK_RETRIES = 2;
|
|
|
27
37
|
* - onToolExecEnd(false) → soCallCount++ / soSucceededEver=true(成功短路终态)
|
|
28
38
|
* - onToolExecEnd(true, err) → soCallCount++ / lastSchemaError=err ?? 通用提示
|
|
29
39
|
* - onTurnEnd() → soCallCount=0 / hookRetryCount++ / lastSchemaError=null
|
|
30
|
-
*
|
|
31
|
-
*
|
|
40
|
+
* (仅当「判定要 steer 且发送成功」时调用——守卫链 toolUse/error/aborted/超上限/
|
|
41
|
+
* 成功短路/发送失败均不调,故 toolUse 保留 soCallCount、超上限保留
|
|
42
|
+
* lastSchemaError,与旧 4-closure 逐点一致)
|
|
43
|
+
*
|
|
44
|
+
* terminal 态(D3/U2):由 loop-gate 在同签名失败达 3 次时经 markTerminal() 置位,
|
|
45
|
+
* turn_end hook 据此不再 steer——防御性保留:shutdown 正常生效时进程已终止,
|
|
46
|
+
* 此分支是 shutdown 失败路径下的保险。terminal 不影响 onToolExecEnd 记录
|
|
47
|
+
* (闸门自身幂等,重复事件无害)。
|
|
32
48
|
*/
|
|
33
49
|
export class RetryState {
|
|
34
50
|
soCallCount = 0;
|
|
35
51
|
soSucceededEver = false;
|
|
36
52
|
hookRetryCount = 0;
|
|
37
53
|
lastSchemaError: string | null = null;
|
|
54
|
+
terminal = false;
|
|
55
|
+
|
|
56
|
+
/** 闸门 terminal 置位(loop-gate 经 index.ts 回调调用;不可逆,仅 reset() 可清)。 */
|
|
57
|
+
markTerminal(): void {
|
|
58
|
+
this.terminal = true;
|
|
59
|
+
}
|
|
38
60
|
|
|
39
61
|
/** 记录一次 structured-output tool 执行结果。hasError = event.isError === true。 */
|
|
40
62
|
onToolExecEnd(hasError: boolean, errorMsg?: string): { shouldSteer: boolean } {
|
|
@@ -54,45 +76,127 @@ export class RetryState {
|
|
|
54
76
|
this.lastSchemaError = null;
|
|
55
77
|
}
|
|
56
78
|
|
|
57
|
-
/**
|
|
79
|
+
/** 五字段归零(当前无调用方;保留作状态机完整契约——含 U2 新增 terminal 态)。 */
|
|
58
80
|
reset(): void {
|
|
59
81
|
this.soCallCount = 0;
|
|
60
82
|
this.soSucceededEver = false;
|
|
61
83
|
this.hookRetryCount = 0;
|
|
62
84
|
this.lastSchemaError = null;
|
|
85
|
+
this.terminal = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** steer 发送失败告警的 appendEntry customType(session JSONL 持久化,不进 LLM 上下文)。 */
|
|
90
|
+
export const HOOK_ENTRY_TYPE = "structured-output:hook";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* steer 发送失败告警(审查项#8 失败路径):双通道落盘(同 loop-gate writeTerminatedLog
|
|
94
|
+
* 惯例)——stderr 直出 + appendEntry 持久化。预算未扣减由「不调 onTurnEnd」结构保证,
|
|
95
|
+
* 下一个正常收尾的轮仍会重试 steer,不产生静默哑火。
|
|
96
|
+
*/
|
|
97
|
+
function writeSteerFailedLog(pi: PiAPI, hookRetryCount: number, err: unknown): void {
|
|
98
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
99
|
+
process.stderr.write(
|
|
100
|
+
`[structured-output hook] steer send failed (retry budget NOT consumed, will retry at next turn end): ${message}\n`,
|
|
101
|
+
);
|
|
102
|
+
try {
|
|
103
|
+
pi.appendEntry(HOOK_ENTRY_TYPE, {
|
|
104
|
+
event: "steer_send_failed",
|
|
105
|
+
hookRetryCount,
|
|
106
|
+
error: message,
|
|
107
|
+
guidance:
|
|
108
|
+
"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.",
|
|
109
|
+
});
|
|
110
|
+
} catch (appendErr) {
|
|
111
|
+
// appendEntry 失败不阻断 hook——stderr 通道已落,此处补诊断(同 cache-probe 惯例)
|
|
112
|
+
process.stderr.write(
|
|
113
|
+
`[structured-output hook] appendEntry failed: ${appendErr instanceof Error ? appendErr.message : String(appendErr)}\n`,
|
|
114
|
+
);
|
|
63
115
|
}
|
|
64
116
|
}
|
|
65
117
|
|
|
66
118
|
/**
|
|
67
|
-
*
|
|
119
|
+
* steer 守卫链(原 turn_end handler 内联守卫提取,判定顺序与旧实现逐条一致)。
|
|
120
|
+
* 命中任一守卫即跳过 steer,且调用方不调 onTurnEnd(toolUse/error/aborted 保留
|
|
121
|
+
* soCallCount、超上限保留 lastSchemaError,与旧 4-closure 逐点一致):
|
|
122
|
+
* 0. 闸门 terminal(D3/U2):同签名失败已满 3 次、shutdown 已发——不再 steer,
|
|
123
|
+
* 让进程终止(防御性保留:shutdown 正常生效时进程已死,到不了这里)
|
|
124
|
+
* 1. 已经成功调用过 structured-output,不再干预
|
|
125
|
+
* 2. 不是合法 turn_end 事件
|
|
126
|
+
* 3. stopReason="toolUse" → 模型还在调工具链,不需要干预
|
|
127
|
+
* 4. stopReason="error"/"aborted"(审查项#9)/"deferred"(F4:pi-ai StopReason
|
|
128
|
+
* 枚举成员,types.d.ts:275——provider 延迟响应挂起,本轮没有可消费 steer 的
|
|
129
|
+
* 收尾点)→ 本轮异常/未收尾终止:此刻注入的 steer 在本轮不会被消费,
|
|
130
|
+
* chatMode 复用子进程时会泄漏成下一轮的陈旧指令——不发送
|
|
131
|
+
* 5. 超过重试上限:放弃,让子进程自然结束(调用方据 result.error 判定失败)
|
|
132
|
+
*/
|
|
133
|
+
function shouldSkipSteer(state: RetryState, event: unknown): boolean {
|
|
134
|
+
if (state.terminal) return true;
|
|
135
|
+
if (state.soSucceededEver) return true;
|
|
136
|
+
if (!isTurnEndEvent(event)) return true;
|
|
137
|
+
const stopReason = event.message?.stopReason;
|
|
138
|
+
if (stopReason === "toolUse") return true;
|
|
139
|
+
if (stopReason === "error" || stopReason === "aborted" || stopReason === "deferred") return true;
|
|
140
|
+
return state.hookRetryCount >= MAX_HOOK_RETRIES;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 构造 steer reminder(原 turn_end handler 内联三元提取)。两种失败形态:
|
|
145
|
+
* - calledButFailed(调了但全是 isError)→ 注入具体校验错误 + 正确 schema
|
|
146
|
+
* - 否则(完全没调用)→ 注入"必须调用"提示 + schema 形状;文案按参数层实际契约
|
|
147
|
+
* (根类型)条件化:object 根 arguments 即 data;非 object 根参数层实际是 {value}
|
|
148
|
+
* 包装(P6)——固定 "arguments ARE the data" 会指导模型直传裸值,必被包装层
|
|
149
|
+
* required:["value"] 拒绝,系统性浪费重试预算。判定与包装判定同源
|
|
150
|
+
* (isObjectRootSchema),与工具 description / ASP 同语汇。
|
|
68
151
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
152
|
+
* 错误块经 truncateText 截断(审查项#1,上限 = STEER_ERROR_MAX_CHARS,与 loop-gate
|
|
153
|
+
* 签名上限语义独立——见 F5 拆分):pi-ai validation.js 的实参回显无截断,
|
|
154
|
+
* 大 payload 失败时原始错误 ≈11K chars。如实口径:截断保留首部 = 错误类型 +
|
|
155
|
+
* 靠前的字段名,列表靠后的字段名可能被截掉;完整 schema 形状由 reminder 内
|
|
156
|
+
* schemaJson 全文另行完整携带,模型修正不依赖错误块的截断尾部。
|
|
73
157
|
*/
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
158
|
+
function buildSteerReminder(
|
|
159
|
+
calledButFailed: boolean,
|
|
160
|
+
lastSchemaError: string | null,
|
|
161
|
+
schemaJson: string,
|
|
162
|
+
isObjectRoot: boolean,
|
|
163
|
+
): string {
|
|
164
|
+
if (calledButFailed) {
|
|
165
|
+
return [
|
|
166
|
+
"[MANDATORY] Your structured-output call FAILED validation:",
|
|
167
|
+
truncateText(lastSchemaError ?? "structured-output call failed", STEER_ERROR_MAX_CHARS),
|
|
168
|
+
"",
|
|
169
|
+
"The schema is enforced by the system (PI_WORKFLOW_SCHEMA) — this tool's parameter schema IS the required shape of your result.",
|
|
170
|
+
`The required schema for your result is: ${schemaJson}`,
|
|
171
|
+
"Fix your arguments to conform to this schema and call the structured-output tool AGAIN.",
|
|
172
|
+
"Do NOT output the result as text — call the tool.",
|
|
173
|
+
].join("\n");
|
|
89
174
|
}
|
|
90
|
-
return
|
|
175
|
+
return isObjectRoot
|
|
176
|
+
? [
|
|
177
|
+
"[MANDATORY] You MUST call the structured-output tool now.",
|
|
178
|
+
"Your task requires a structured output. Do NOT respond with plain text.",
|
|
179
|
+
`Your arguments ARE the data — call structured-output with your result as the tool's arguments, matching this shape: ${schemaJson}`,
|
|
180
|
+
"The schema is enforced by the system; your arguments are validated against the authoritative schema automatically.",
|
|
181
|
+
"This is enforced by the workflow system. Just call the tool.",
|
|
182
|
+
].join("\n")
|
|
183
|
+
: [
|
|
184
|
+
"[MANDATORY] You MUST call the structured-output tool now.",
|
|
185
|
+
"Your task requires a structured output. Do NOT respond with plain text.",
|
|
186
|
+
`Call structured-output with a single argument \`{value: <data>}\` — put the result itself in \`value\`, and it must conform to this shape: ${schemaJson}`,
|
|
187
|
+
"Non-object schemas are wrapped in a `value` field because tool call arguments must be objects.",
|
|
188
|
+
"Validation errors may reference paths starting with `value.` (e.g. `value.0`, `value.name`): that prefix addresses the wrapper, not your data — strip it to locate the offending field.",
|
|
189
|
+
"The schema is enforced by the system; the `value` field is validated against the authoritative schema automatically.",
|
|
190
|
+
"This is enforced by the workflow system. Just call the tool.",
|
|
191
|
+
].join("\n");
|
|
91
192
|
}
|
|
92
193
|
|
|
93
194
|
/**
|
|
94
195
|
* 注册 turn_end hook,检查模型是否成功调用 structured-output 工具。
|
|
95
196
|
* 未成功时通过 pi.sendUserMessage({deliverAs:"steer"}) 注入 steering message 重试。
|
|
197
|
+
* 最多重试 MAX_HOOK_RETRIES 次,防止无限循环。
|
|
198
|
+
*
|
|
199
|
+
* @returns 共享的 RetryState(U2:index.ts 拿它接线 loop-gate 的 onTerminal 回调)。
|
|
96
200
|
*
|
|
97
201
|
* 两种失败形态都会触发 steer:
|
|
98
202
|
* 1. 完全没调用(soCallCount === 0)→ 注入"必须调用"提示 + 正确 schema
|
|
@@ -104,9 +208,14 @@ function extractToolErrorText(result: unknown): string | undefined {
|
|
|
104
208
|
* 检测时序:Pi 保证同 turn 内所有 tool_execution_end 都在 turn_end 之前触发,
|
|
105
209
|
* 故 turn_end 读取的状态已反映本 turn 全部 tool 调用结果。
|
|
106
210
|
*/
|
|
107
|
-
export function setupWorkflowHook(pi: PiAPI, schemaJson: string):
|
|
211
|
+
export function setupWorkflowHook(pi: PiAPI, schemaJson: string): RetryState {
|
|
108
212
|
const state = new RetryState();
|
|
109
213
|
|
|
214
|
+
// 根类型判定与 {value} 包装判定同源(isObjectRootSchema,P6):注册期
|
|
215
|
+
// createWorkflowToolDefinition 已对同一 schema 完成 assertJsonSchemaRoot fail-fast,
|
|
216
|
+
// 此处解析失败不可能到达;真失败时判定为非 object 根 → 包装契约文案(保守方向)。
|
|
217
|
+
const isObjectRoot = isObjectRootSchema(tryParseJson(schemaJson));
|
|
218
|
+
|
|
110
219
|
// 追踪 structured-output 调用结果:
|
|
111
220
|
// 成功 → soSucceededEver=true(终态,后续不再干预)
|
|
112
221
|
// 失败 → soCallCount++,记录 lastSchemaError,由 turn_end 决定是否 steer 重试
|
|
@@ -120,41 +229,32 @@ export function setupWorkflowHook(pi: PiAPI, schemaJson: string): void {
|
|
|
120
229
|
});
|
|
121
230
|
|
|
122
231
|
pi.on("turn_end", async (event: unknown) => {
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
// 2. 不是合法 turn_end 事件
|
|
127
|
-
// 3. stopReason="toolUse" → 模型还在调工具链,不需要干预
|
|
128
|
-
// 4. 超过重试上限:放弃,让子进程自然结束(调用方据 result.error 判定失败)
|
|
129
|
-
if (state.soSucceededEver) return;
|
|
130
|
-
if (!isTurnEndEvent(event)) return;
|
|
131
|
-
if (event.message?.stopReason === "toolUse") return;
|
|
132
|
-
if (state.hookRetryCount >= MAX_HOOK_RETRIES) return;
|
|
232
|
+
// 守卫链(判定明细见 shouldSkipSteer 注释):命中即直接 return,不调
|
|
233
|
+
// onTurnEnd——预算不扣减,状态保留到下一个正常收尾的轮再判定 steer。
|
|
234
|
+
if (shouldSkipSteer(state, event)) return;
|
|
133
235
|
|
|
134
236
|
// 完全没调用 OR 调了但全是失败 → 都需要 steer。两种情况共用重试上限与计数。
|
|
135
237
|
const calledButFailed = state.soCallCount > 0;
|
|
136
238
|
// 构造 reminder 时 lastSchemaError 必须仍是本 turn 的错误文本,
|
|
137
|
-
// 故 onTurnEnd()(清空 lastSchemaError
|
|
138
|
-
const reminder = calledButFailed
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"This is enforced by the workflow system. Just call the tool.",
|
|
154
|
-
].join("\n");
|
|
155
|
-
|
|
156
|
-
// 按本 turn 重置计数、累计重试次数、清空 lastSchemaError(steer 后本 turn 状态归零)
|
|
239
|
+
// 故 onTurnEnd()(清空 lastSchemaError)必须在发送成功之后调用。
|
|
240
|
+
const reminder = buildSteerReminder(calledButFailed, state.lastSchemaError, schemaJson, isObjectRoot);
|
|
241
|
+
|
|
242
|
+
// 审查项#8:await 发送结果——发送失败(如 compaction 中 prompt() 抛错 / 扩展已
|
|
243
|
+
// 被 assertActive 拒绝)不扣减重试预算(不调 onTurnEnd),否则 fire-and-forget
|
|
244
|
+
// 丢一份 steer + 白扣一次预算,两次即永久哑火。
|
|
245
|
+
// pi 0.84.1 实装(loader.js):extension 侧 sendUserMessage 同步转发且吞掉异步
|
|
246
|
+
// rejection(转 emitError)返回 void——await 对 undefined 立即解析;此处的
|
|
247
|
+
// try/catch 兜住同步 throw(assertActive)与未来 pi 返回真 Promise 的形态。
|
|
248
|
+
try {
|
|
249
|
+
await pi.sendUserMessage(reminder, { deliverAs: "steer" });
|
|
250
|
+
} catch (err) {
|
|
251
|
+
writeSteerFailedLog(pi, state.hookRetryCount, err);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// 发送成功才按本 turn 重置计数、累计重试次数、清空 lastSchemaError
|
|
157
255
|
state.onTurnEnd();
|
|
158
|
-
pi.sendUserMessage(reminder, { deliverAs: "steer" });
|
|
159
256
|
});
|
|
257
|
+
|
|
258
|
+
// U2:暴露共享状态——index.ts 接线 loop-gate 的 onTerminal 回调(markTerminal)
|
|
259
|
+
return state;
|
|
160
260
|
}
|