@zhushanwen/pi-structured-output 5.0.0-dev.0 → 5.0.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 +1 -1
- package/src/ajv-validator.ts +27 -0
- package/src/execute.ts +240 -0
- package/src/index.ts +10 -425
- package/src/schema-guards.ts +101 -0
- package/src/tool-definition.ts +81 -0
- package/src/workflow-hook.ts +160 -0
package/package.json
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ajv 编译缓存 — 纯逻辑叶节点(零业务依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 从 index.ts 拆出:WeakMap 缓存 + getOrCompileValidator 编译入口。
|
|
5
|
+
* schema 对象引用即缓存 key(WeakMap 不强引用,GC 友好);
|
|
6
|
+
* boolean 根 schema 不缓存(见下注释)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import Ajv, { type ValidateFunction } from "ajv";
|
|
10
|
+
|
|
11
|
+
// ── Ajv WeakMap cache ─────────────────────────────────────────
|
|
12
|
+
const ajvCache = new WeakMap<object, ValidateFunction>();
|
|
13
|
+
|
|
14
|
+
export function getOrCompileValidator(schema: Record<string, unknown> | boolean): ValidateFunction {
|
|
15
|
+
// boolean 根 schema(true=接受一切,false=拒绝一切)是合法 draft-07,
|
|
16
|
+
// 但 boolean 不能做 WeakMap key,故不缓存(编译结果恒定,重复编译无副作用)。
|
|
17
|
+
if (typeof schema === "boolean") {
|
|
18
|
+
return new Ajv({ strict: false }).compile(schema);
|
|
19
|
+
}
|
|
20
|
+
const cached = ajvCache.get(schema);
|
|
21
|
+
if (cached) return cached;
|
|
22
|
+
|
|
23
|
+
const ajv = new Ajv({ strict: false });
|
|
24
|
+
const validate = ajv.compile(schema);
|
|
25
|
+
ajvCache.set(schema, validate);
|
|
26
|
+
return validate;
|
|
27
|
+
}
|
package/src/execute.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* executeStructuredOutput 编排 + 校验双函数(IF-6 拆分)。
|
|
3
|
+
*
|
|
4
|
+
* 两种模式:
|
|
5
|
+
* - 权威模式(workflow):`authoritativeSchema` 存在时,只用它校验 data,LLM 传入的
|
|
6
|
+
* `schema` 不参与校验(仅用于错误回显)。这从根上杜绝 LLM 自报 schema 自洽绕过
|
|
7
|
+
* ([HISTORICAL] 2026-08-01 事故:ds-flash 重写 add_channels.items 的 schema 后
|
|
8
|
+
* 自洽通过,4 条 channel 修复静默丢失)。
|
|
9
|
+
* - 日常模式(交互式):无 `authoritativeSchema`,走 validateAgainstSelfReported 防御链。
|
|
10
|
+
*
|
|
11
|
+
* 权威模式设防(SO-1 修复):权威分支不再跳过防御链——keyword-less 权威 schema
|
|
12
|
+
* ({} / {a:1})被显式拒绝(ERR-3),boolean true(accept-all,无形状约束)被拦截
|
|
13
|
+
* (ERR-7),否则 workflow 声明的约束会在 ajv strict:false 下静默失效。
|
|
14
|
+
*
|
|
15
|
+
* 日常模式防御顺序(编译前拦截,治静默腐败的根):
|
|
16
|
+
* 1. 互换检测 — schema 像数据(无 keyword)且 data 像 schema(有 keyword)→ 抛纠错
|
|
17
|
+
* 2. keyword-less schema 拒绝 — schema 是对象但无任何识别 keyword({} / {a:1})
|
|
18
|
+
* → 抛 "no recognized keyword",否则 ajv strict:false 会编译成"接受一切"
|
|
19
|
+
* 3. ajv 编译失败 → 抛 "Invalid JSON Schema"(含回显)
|
|
20
|
+
* 4. 校验失败 → 抛 "Schema validation failed"(含回显)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ValidateFunction } from "ajv";
|
|
24
|
+
|
|
25
|
+
import { getOrCompileValidator } from "./ajv-validator.js";
|
|
26
|
+
import {
|
|
27
|
+
assertJsonSchemaRoot,
|
|
28
|
+
CORRECT_USAGE_HINT,
|
|
29
|
+
echo,
|
|
30
|
+
hasSchemaKeyword,
|
|
31
|
+
isPlainObject,
|
|
32
|
+
tryParseJson,
|
|
33
|
+
} from "./schema-guards.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 权威模式校验(IF-6)。authSchema 由 workflow 脚本(PI_WORKFLOW_SCHEMA env)注入,
|
|
37
|
+
* 是唯一校验权威——LLM 传入的 schema 不参与校验,仅用于编排层错误回显。
|
|
38
|
+
*
|
|
39
|
+
* 权威模式不再跳过日常防御链(SO-1 修复):keyword-less 权威 schema 必须先过
|
|
40
|
+
* schema-guards 检查,否则 ajv strict:false 会静默编译成 accept-all,workflow 的
|
|
41
|
+
* 形状约束失效且零报错(与 08-01 事故同类的静默腐败路径)。
|
|
42
|
+
*
|
|
43
|
+
* @returns 校验通过恒为 true;任何失败形态抛错(带恢复指引 + 回显)。
|
|
44
|
+
*/
|
|
45
|
+
export function validateWithAuthoritative(data: unknown, authSchema: object): boolean {
|
|
46
|
+
// 类型收窄:签名声明 object(C2 契约),hasSchemaKeyword 需要索引签名。
|
|
47
|
+
// 编排层已 assertJsonSchemaRoot 保证 plain object;直接调用方传 plain object。
|
|
48
|
+
if (!isPlainObject(authSchema)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
"Authoritative schema (PI_WORKFLOW_SCHEMA) must be a plain object, got "
|
|
51
|
+
+ typeof authSchema,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 1. keyword-less 拒绝(ERR-3):权威 schema 必须用 JSON Schema 关键字描述形状。
|
|
56
|
+
// 无 keyword 的对象会被 ajv 编译成"接受一切",必须显式拦截并给恢复指引。
|
|
57
|
+
if (!hasSchemaKeyword(authSchema)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
"Authoritative schema (PI_WORKFLOW_SCHEMA) has no recognized keyword. "
|
|
60
|
+
+ "A workflow schema must describe shape via type/properties/items/... "
|
|
61
|
+
+ "👉 检查 workflow 脚本的 outputSchema 定义,补全 JSON Schema 关键字。 "
|
|
62
|
+
+ `Received schema=${echo(authSchema)}, data=${echo(data)}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. 编译 + 校验。编译失败抛清晰错误(含权威 schema 回显供 workflow 作者修正)。
|
|
67
|
+
let validate: ValidateFunction;
|
|
68
|
+
try {
|
|
69
|
+
validate = getOrCompileValidator(authSchema);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Invalid authoritative JSON Schema (from PI_WORKFLOW_SCHEMA): ${(e as Error).message}. `
|
|
73
|
+
+ `The authoritative schema (PI_WORKFLOW_SCHEMA) is: ${echo(authSchema)}. `
|
|
74
|
+
+ `Received data=${echo(data)}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const valid = validate(data);
|
|
79
|
+
if (!valid) {
|
|
80
|
+
const errors = validate.errors
|
|
81
|
+
?.map((err) => `${err.instancePath} ${err.message}`)
|
|
82
|
+
.join("; ");
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Schema validation failed (authoritative): ${errors}. `
|
|
85
|
+
+ `The authoritative schema (PI_WORKFLOW_SCHEMA) is: ${echo(authSchema)}. `
|
|
86
|
+
+ `Received data=${echo(data)}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 日常模式防御链(原 4 步原样迁移):LLM 自报 schema 的校验路径。
|
|
94
|
+
* 编译前拦截两类静默腐败形态:互换(schema 像数据 + data 像 schema)和
|
|
95
|
+
* keyword-less schema({} / {a:1} 会被 ajv strict:false 编译成"接受一切")。
|
|
96
|
+
*
|
|
97
|
+
* @returns 校验通过恒为 true;任何失败形态抛错(带 CORRECT_USAGE_HINT + 回显)。
|
|
98
|
+
*/
|
|
99
|
+
export function validateAgainstSelfReported(schema: unknown, data: unknown): boolean {
|
|
100
|
+
// 1. 互换检测:schema 像数据(对象无 keyword)且 data 像 schema(对象有 keyword)。
|
|
101
|
+
// 这是最严重的静默腐败路径——若放行,ajv 会把"数据形态的 schema"编译成接受一切,
|
|
102
|
+
// 真正的 schema(此时在 data 里)被丢弃,校验通过并存入垃圾。
|
|
103
|
+
if (isPlainObject(schema) && !hasSchemaKeyword(schema) && isPlainObject(data) && hasSchemaKeyword(data)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
"Likely swapped: schema looks like data and data looks like a schema. "
|
|
106
|
+
+ CORRECT_USAGE_HINT
|
|
107
|
+
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. keyword-less schema 拒绝:治静默腐败的根。{} / {a:1} 这类对象会被
|
|
112
|
+
// ajv strict:false 编译成"接受一切"的 validator,模型把答案塞进 schema 时会静默通过。
|
|
113
|
+
if (isPlainObject(schema) && !hasSchemaKeyword(schema)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
"Invalid JSON Schema: schema has no recognized keyword "
|
|
116
|
+
+ "(type/properties/items/enum/...). If you passed the answer value as schema, "
|
|
117
|
+
+ "you likely swapped schema and data. "
|
|
118
|
+
+ CORRECT_USAGE_HINT
|
|
119
|
+
+ `Received schema=${echo(schema)}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 3. ajv 编译。schema 此时可能是 object(过 keyword 检查)、boolean(合法 draft-07 根)、
|
|
124
|
+
// 或 string/number/array/null(非法 → 显式抛错给清晰提示)。getOrCompileValidator 只接受
|
|
125
|
+
// object|boolean,消除原先的 `as Record<string,unknown>` 不安全 cast。
|
|
126
|
+
let validate: ValidateFunction;
|
|
127
|
+
try {
|
|
128
|
+
if (isPlainObject(schema) || typeof schema === "boolean") {
|
|
129
|
+
validate = getOrCompileValidator(schema);
|
|
130
|
+
} else {
|
|
131
|
+
throw new Error(`schema must be a JSON Schema object or boolean, got ${typeof schema}`);
|
|
132
|
+
}
|
|
133
|
+
} catch (e) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Invalid JSON Schema: ${(e as Error).message}. `
|
|
136
|
+
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 4. 校验
|
|
141
|
+
const valid = validate(data);
|
|
142
|
+
if (!valid) {
|
|
143
|
+
const errors = validate.errors
|
|
144
|
+
?.map((err) => `${err.instancePath} ${err.message}`)
|
|
145
|
+
.join("; ");
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Schema validation failed: ${errors}. `
|
|
148
|
+
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 执行 schema 校验。从 createToolDefinition.execute 抽出以便单元测试直接调用。
|
|
156
|
+
*
|
|
157
|
+
* 编排:tryParseJson 归一 → 权威分支(assertJsonSchemaRoot 收窄 + boolean 拦截 +
|
|
158
|
+
* validateWithAuthoritative)→ 日常分支 validateAgainstSelfReported。
|
|
159
|
+
*/
|
|
160
|
+
export async function executeStructuredOutput(params: {
|
|
161
|
+
schema: unknown;
|
|
162
|
+
data: unknown;
|
|
163
|
+
/** 权威 schema(workflow 模式由 PI_WORKFLOW_SCHEMA env 注入)。存在时成为唯一校验权威。 */
|
|
164
|
+
authoritativeSchema?: unknown;
|
|
165
|
+
}): Promise<{
|
|
166
|
+
content: Array<{ type: "text"; text: string }>;
|
|
167
|
+
// data 可能是 primitive/array/object(根 schema 决定),故 details 为 unknown。
|
|
168
|
+
// 测试断言 toEqual(42)/toEqual(true)/toEqual(["a","b","c"]),不可窄化为 Record。
|
|
169
|
+
details: unknown;
|
|
170
|
+
}> {
|
|
171
|
+
// Normalize: some models pass schema/data as JSON strings instead of objects
|
|
172
|
+
const schema = tryParseJson(params.schema);
|
|
173
|
+
const data = tryParseJson(params.data);
|
|
174
|
+
|
|
175
|
+
// ── 权威模式(workflow):用 PI_WORKFLOW_SCHEMA 声明的期望 schema 校验 data。 ──
|
|
176
|
+
// LLM 传入的 schema 仅用于错误回显(告知期望形态),不参与校验——否则 LLM
|
|
177
|
+
// 可同时控制 schema 与 data 自洽绕过任何约束。日常模式无权威 schema 走下方防御链。
|
|
178
|
+
const authoritative =
|
|
179
|
+
params.authoritativeSchema !== undefined ? tryParseJson(params.authoritativeSchema) : undefined;
|
|
180
|
+
if (authoritative !== undefined) {
|
|
181
|
+
try {
|
|
182
|
+
// 先用 assert 函数把 unknown 收窄为 Record<string,unknown> | boolean,
|
|
183
|
+
// 使后续分支在类型层面成立(type-safety)。非 object/boolean 抛清晰错误,
|
|
184
|
+
// 由外层 catch 包成含 echo 的错误。
|
|
185
|
+
assertJsonSchemaRoot(authoritative);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Invalid authoritative JSON Schema (from PI_WORKFLOW_SCHEMA): ${(e as Error).message}. `
|
|
189
|
+
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (authoritative === true) {
|
|
194
|
+
// ERR-7:boolean true(accept-all)不提供任何形状约束,workflow 用它等于没校验。
|
|
195
|
+
// 必须改为 object schema 才构成真正的约束(keyword-less 拒绝见 validateWithAuthoritative)。
|
|
196
|
+
throw new Error(
|
|
197
|
+
"Authoritative schema (PI_WORKFLOW_SCHEMA) is boolean true (accept-all), "
|
|
198
|
+
+ "provides no shape constraint. 👉 改为带 type/properties/items 的 object schema。"
|
|
199
|
+
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (authoritative === false) {
|
|
204
|
+
// boolean false = reject-all(draft-07 合法根,有形状约束语义:拒绝一切)。
|
|
205
|
+
// 保留原行为:编译 + 校验失败抛 'Schema validation failed (authoritative)'。
|
|
206
|
+
const validate = getOrCompileValidator(false);
|
|
207
|
+
const valid = validate(data);
|
|
208
|
+
if (!valid) {
|
|
209
|
+
const errors = validate.errors
|
|
210
|
+
?.map((err) => `${err.instancePath} ${err.message}`)
|
|
211
|
+
.join("; ");
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Schema validation failed (authoritative): ${errors}. `
|
|
214
|
+
+ `The authoritative schema (PI_WORKFLOW_SCHEMA) is: ${echo(authoritative)}. `
|
|
215
|
+
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
// object 权威 schema:过 keyword-less 检查(ERR-3)+ 编译 + 校验。
|
|
220
|
+
validateWithAuthoritative(data, authoritative);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
content: [
|
|
225
|
+
{ type: "text" as const, text: "Structured output recorded successfully." },
|
|
226
|
+
],
|
|
227
|
+
details: data,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── 日常模式防御链(validateAgainstSelfReported:互换/keyword-less/编译/校验)──
|
|
232
|
+
validateAgainstSelfReported(schema, data);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
content: [
|
|
236
|
+
{ type: "text" as const, text: "Structured output recorded successfully." },
|
|
237
|
+
],
|
|
238
|
+
details: data,
|
|
239
|
+
};
|
|
240
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,438 +9,23 @@
|
|
|
9
9
|
* turn_end 时检查模型是否调用了 structured-output 工具。
|
|
10
10
|
* 如果没调 → 通过 pi.sendUserMessage() 注入 steering message 强制调用。
|
|
11
11
|
* 最多重试 2 次,防止无限循环。
|
|
12
|
+
*
|
|
13
|
+
* 模块拆分(M4):实现体分布于 ajv-validator.ts(编译缓存)/
|
|
14
|
+
* schema-guards.ts(形态守卫)/ execute.ts(校验编排)/ tool-definition.ts(工具定义)/
|
|
15
|
+
* workflow-hook.ts(hook + RetryState)。本文件仅剩 entry 装配与 re-export。
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
|
-
import { Type } from "typebox";
|
|
15
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
|
|
19
|
+
|
|
20
|
+
import { executeStructuredOutput } from "./execute.js";
|
|
21
|
+
import { createToolDefinition, ENV_SCHEMA } from "./tool-definition.js";
|
|
22
|
+
import { RetryState, setupWorkflowHook } from "./workflow-hook.js";
|
|
17
23
|
|
|
18
24
|
/** Pi Extension API — properly typed via ExtensionAPI from pi-coding-agent SDK */
|
|
19
25
|
type PiAPI = ExtensionAPI;
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const MAX_HOOK_RETRIES = 2;
|
|
24
|
-
|
|
25
|
-
// ── Ajv WeakMap cache ─────────────────────────────────────────
|
|
26
|
-
const ajvCache = new WeakMap<object, ValidateFunction>();
|
|
27
|
-
|
|
28
|
-
function getOrCompileValidator(schema: Record<string, unknown> | boolean): ValidateFunction {
|
|
29
|
-
// boolean 根 schema(true=接受一切,false=拒绝一切)是合法 draft-07,
|
|
30
|
-
// 但 boolean 不能做 WeakMap key,故不缓存(编译结果恒定,重复编译无副作用)。
|
|
31
|
-
if (typeof schema === "boolean") {
|
|
32
|
-
return new Ajv({ strict: false }).compile(schema);
|
|
33
|
-
}
|
|
34
|
-
const cached = ajvCache.get(schema);
|
|
35
|
-
if (cached) return cached;
|
|
36
|
-
|
|
37
|
-
const ajv = new Ajv({ strict: false });
|
|
38
|
-
const validate = ajv.compile(schema);
|
|
39
|
-
ajvCache.set(schema, validate);
|
|
40
|
-
return validate;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ── Schema-shape guards (swap detection + silent-corruption prevention) ──
|
|
44
|
-
//
|
|
45
|
-
// 核心问题:schema 和 data 参数都用 Type.Unknown(),结构无差别。弱模型常把答案
|
|
46
|
-
// 塞进 schema、把形状塞进 data。因 ajv strict:false 把无 keyword 的对象编译成
|
|
47
|
-
// "接受一切" 的 validator,互换后会校验通过、存垃圾、无报错(静默腐败)。
|
|
48
|
-
// 这组守卫在编译前拦截两类形态:互换(schema 像数据 + data 像 schema)和
|
|
49
|
-
// keyword-less schema({} / {a:1} 这种会被 ajv 静默放行)。
|
|
50
|
-
|
|
51
|
-
/** JSON Schema draft-07 识别 keyword。只要 schema 含其一就认为是"真 schema"。 */
|
|
52
|
-
const SCHEMA_KEYWORDS = [
|
|
53
|
-
// 核心类型
|
|
54
|
-
"type",
|
|
55
|
-
// object
|
|
56
|
-
"properties", "required", "additionalProperties", "patternProperties",
|
|
57
|
-
"minProperties", "maxProperties",
|
|
58
|
-
// array
|
|
59
|
-
"items", "additionalItems", "minItems", "maxItems", "uniqueItems",
|
|
60
|
-
// enum / const
|
|
61
|
-
"enum", "const",
|
|
62
|
-
// 组合
|
|
63
|
-
"allOf", "anyOf", "oneOf", "not",
|
|
64
|
-
// 条件验证(draft-07)
|
|
65
|
-
"if", "then", "else",
|
|
66
|
-
// 依赖与约束
|
|
67
|
-
"dependencies", "propertyNames", "contains",
|
|
68
|
-
// 引用与定义
|
|
69
|
-
"$ref", "$id", "$defs", "definitions",
|
|
70
|
-
// 数值
|
|
71
|
-
"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf",
|
|
72
|
-
// 字符串
|
|
73
|
-
"minLength", "maxLength", "pattern", "format",
|
|
74
|
-
] as const;
|
|
75
|
-
|
|
76
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
77
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function hasSchemaKeyword(obj: Record<string, unknown>): boolean {
|
|
81
|
-
return SCHEMA_KEYWORDS.some((keyword) => keyword in obj);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** 错误回显长度上限(截断长 schema/data,避免错误消息爆炸)。 */
|
|
85
|
-
const ECHO_MAX_CHARS = 200;
|
|
86
|
-
|
|
87
|
-
function echo(value: unknown): string {
|
|
88
|
-
let str: string;
|
|
89
|
-
try {
|
|
90
|
-
// JSON.stringify(undefined) 返回 undefined(不是 throw),需 ?? 兜底,
|
|
91
|
-
// 否则后续 str.length 会 "Cannot read properties of undefined"。
|
|
92
|
-
str = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
|
|
93
|
-
} catch {
|
|
94
|
-
str = String(value);
|
|
95
|
-
}
|
|
96
|
-
return str.length <= ECHO_MAX_CHARS ? str : `${str.slice(0, ECHO_MAX_CHARS)}...`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* 尝试 JSON.parse;失败(malformed JSON)时保留原值,让 Ajv 拒绝。
|
|
101
|
-
* 模型有时把 schema/data 当 JSON 字符串传;parse 失败不是错误,保持原样让下游校验拒绝。
|
|
102
|
-
* catch 里有实质处理(决定返回原值),满足 taste/no-silent-catch。
|
|
103
|
-
*/
|
|
104
|
-
function tryParseJson(raw: unknown): unknown {
|
|
105
|
-
if (typeof raw !== "string") return raw;
|
|
106
|
-
try {
|
|
107
|
-
return JSON.parse(raw);
|
|
108
|
-
} catch {
|
|
109
|
-
return raw; // malformed JSON → 保留原字符串,Ajv 会拒绝
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* 把 authoritative schema 的 unknown 收窄为合法 JSON Schema 根类型(object | boolean)。
|
|
115
|
-
* draft-07 允许 boolean 根 schema(true=接受一切,false=拒绝一切)。
|
|
116
|
-
* 独立守卫使后续 getOrCompileValidator(authoritative) 在类型层面也成立,
|
|
117
|
-
* 避免在守卫块外直接用 unknown。非合法形态抛清晰错误(与日常模式第 235 行同构)。
|
|
118
|
-
*/
|
|
119
|
-
function assertJsonSchemaRoot(value: unknown): asserts value is Record<string, unknown> | boolean {
|
|
120
|
-
if (!(isPlainObject(value) || typeof value === "boolean")) {
|
|
121
|
-
throw new Error(`authoritative schema must be a JSON Schema object or boolean, got ${typeof value}`);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** turn_end event 是否可安全访问 message.stopReason(用于判断模型是否还在调工具链)。 */
|
|
126
|
-
function isTurnEndEvent(e: unknown): e is { message?: { stopReason?: string } } {
|
|
127
|
-
return typeof e === "object" && e !== null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** tool_execution_end event 结构守卫(替代直接 cast,配合 taste/no-unsafe-cast)。 */
|
|
131
|
-
function isToolExecutionEndEvent(
|
|
132
|
-
e: unknown,
|
|
133
|
-
): e is { toolName: unknown; isError: unknown; result?: unknown } {
|
|
134
|
-
return typeof e === "object" && e !== null && "toolName" in e && "isError" in e;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** swap 检测 + keyword-less schema 拒绝的纠错文案前缀,所有相关错误共用。 */
|
|
138
|
-
const CORRECT_USAGE_HINT =
|
|
139
|
-
"Correct: structured_output({schema:{type:'object',properties:{...}}, data:{...actual values}}). ";
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* 执行 schema 校验。从 createToolDefinition.execute 抽出以便单元测试直接调用。
|
|
143
|
-
*
|
|
144
|
-
* 两种模式:
|
|
145
|
-
* - 权威模式(workflow):`authoritativeSchema` 存在时,只用它校验 data,LLM 传入的
|
|
146
|
-
* `schema` 不参与校验(仅用于错误回显)。这从根上杜绝 LLM 自报 schema 自洽绕过
|
|
147
|
-
* ([HISTORICAL] 2026-08-01 事故:ds-flash 重写 add_channels.items 的 schema 后
|
|
148
|
-
* 自洽通过,4 条 channel 修复静默丢失)。权威分支提前 return,跳过日常防御链
|
|
149
|
-
* (权威 schema 由 workflow 脚本写死,不存在互换/keyword-less 风险)。
|
|
150
|
-
* - 日常模式(交互式):无 `authoritativeSchema`,走下方防御链。
|
|
151
|
-
*
|
|
152
|
-
* 日常模式防御顺序(编译前拦截,治静默腐败的根):
|
|
153
|
-
* 1. 互换检测 — schema 像 data(无 keyword)且 data 像 schema(有 keyword)→ 抛纠错
|
|
154
|
-
* 2. keyword-less schema 拒绝 — schema 是对象但无任何识别 keyword({} / {a:1})
|
|
155
|
-
* → 抛 "no recognized keyword",否则 ajv strict:false 会编译成"接受一切"
|
|
156
|
-
* 3. ajv 编译失败 → 抛 "Invalid JSON Schema"(含回显)
|
|
157
|
-
* 4. 校验失败 → 抛 "Schema validation failed"(含回显)
|
|
158
|
-
*/
|
|
159
|
-
export async function executeStructuredOutput(params: {
|
|
160
|
-
schema: unknown;
|
|
161
|
-
data: unknown;
|
|
162
|
-
/** 权威 schema(workflow 模式由 PI_WORKFLOW_SCHEMA env 注入)。存在时成为唯一校验权威。 */
|
|
163
|
-
authoritativeSchema?: unknown;
|
|
164
|
-
}): Promise<{
|
|
165
|
-
content: Array<{ type: "text"; text: string }>;
|
|
166
|
-
// data 可能是 primitive/array/object(根 schema 决定),故 details 为 unknown。
|
|
167
|
-
// 测试断言 toEqual(42)/toEqual(true)/toEqual(["a","b","c"]),不可窄化为 Record。
|
|
168
|
-
details: unknown;
|
|
169
|
-
}> {
|
|
170
|
-
// Normalize: some models pass schema/data as JSON strings instead of objects
|
|
171
|
-
const schema = tryParseJson(params.schema);
|
|
172
|
-
const data = tryParseJson(params.data);
|
|
173
|
-
|
|
174
|
-
// ── 权威模式(workflow):用 PI_WORKFLOW_SCHEMA 声明的期望 schema 校验 data。 ──
|
|
175
|
-
// LLM 传入的 schema 仅用于错误回显(告知期望形态),不参与校验——否则 LLM
|
|
176
|
-
// 可同时控制 schema 与 data 自洽绕过任何约束。日常模式无权威 schema 走下方防御链。
|
|
177
|
-
const authoritative =
|
|
178
|
-
params.authoritativeSchema !== undefined ? tryParseJson(params.authoritativeSchema) : undefined;
|
|
179
|
-
if (authoritative !== undefined) {
|
|
180
|
-
let validate: ValidateFunction;
|
|
181
|
-
try {
|
|
182
|
-
// 先用 assert 函数把 unknown 收窄为 Record<string,unknown> | boolean,
|
|
183
|
-
// 使后续 getOrCompileValidator(authoritative) 在类型层面也成立(type-safety)。
|
|
184
|
-
// 运行时行为不变:非 object/boolean 抛清晰错误,由外层 catch 包成含 echo 的错误。
|
|
185
|
-
assertJsonSchemaRoot(authoritative);
|
|
186
|
-
validate = getOrCompileValidator(authoritative);
|
|
187
|
-
} catch (e) {
|
|
188
|
-
throw new Error(
|
|
189
|
-
`Invalid authoritative JSON Schema (from PI_WORKFLOW_SCHEMA): ${(e as Error).message}. `
|
|
190
|
-
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
const valid = validate(data);
|
|
194
|
-
if (!valid) {
|
|
195
|
-
const errors = validate.errors
|
|
196
|
-
?.map((err) => `${err.instancePath} ${err.message}`)
|
|
197
|
-
.join("; ");
|
|
198
|
-
throw new Error(
|
|
199
|
-
`Schema validation failed (authoritative): ${errors}. `
|
|
200
|
-
+ `The authoritative schema (PI_WORKFLOW_SCHEMA) is: ${echo(authoritative)}. `
|
|
201
|
-
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
return {
|
|
205
|
-
content: [
|
|
206
|
-
{ type: "text" as const, text: "Structured output recorded successfully." },
|
|
207
|
-
],
|
|
208
|
-
details: data,
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ── 日常模式防御链 ──────────────────────────────────────────────
|
|
213
|
-
|
|
214
|
-
// 1. 互换检测:schema 像数据(对象无 keyword)且 data 像 schema(对象有 keyword)。
|
|
215
|
-
// 这是最严重的静默腐败路径——若放行,ajv 会把"数据形态的 schema"编译成接受一切,
|
|
216
|
-
// 真正的 schema(此时在 data 里)被丢弃,校验通过并存入垃圾。
|
|
217
|
-
if (isPlainObject(schema) && !hasSchemaKeyword(schema) && isPlainObject(data) && hasSchemaKeyword(data)) {
|
|
218
|
-
throw new Error(
|
|
219
|
-
"Likely swapped: schema looks like data and data looks like a schema. "
|
|
220
|
-
+ CORRECT_USAGE_HINT
|
|
221
|
-
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// 2. keyword-less schema 拒绝:治静默腐败的根。{} / {a:1} 这类对象会被
|
|
226
|
-
// ajv strict:false 编译成"接受一切"的 validator,模型把答案塞进 schema 时会静默通过。
|
|
227
|
-
if (isPlainObject(schema) && !hasSchemaKeyword(schema)) {
|
|
228
|
-
throw new Error(
|
|
229
|
-
"Invalid JSON Schema: schema has no recognized keyword "
|
|
230
|
-
+ "(type/properties/items/enum/...). If you passed the answer value as schema, "
|
|
231
|
-
+ "you likely swapped schema and data. "
|
|
232
|
-
+ CORRECT_USAGE_HINT
|
|
233
|
-
+ `Received schema=${echo(schema)}`,
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// 3. ajv 编译。schema 此时可能是 object(过 keyword 检查)、boolean(合法 draft-07 根)、
|
|
238
|
-
// 或 string/number/array/null(非法 → 显式抛错给清晰提示)。getOrCompileValidator 只接受
|
|
239
|
-
// object|boolean,消除原先的 `as Record<string,unknown>` 不安全 cast。
|
|
240
|
-
let validate: ValidateFunction;
|
|
241
|
-
try {
|
|
242
|
-
if (isPlainObject(schema) || typeof schema === "boolean") {
|
|
243
|
-
validate = getOrCompileValidator(schema);
|
|
244
|
-
} else {
|
|
245
|
-
throw new Error(`schema must be a JSON Schema object or boolean, got ${typeof schema}`);
|
|
246
|
-
}
|
|
247
|
-
} catch (e) {
|
|
248
|
-
throw new Error(
|
|
249
|
-
`Invalid JSON Schema: ${(e as Error).message}. `
|
|
250
|
-
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// 4. 校验
|
|
255
|
-
const valid = validate(data);
|
|
256
|
-
if (!valid) {
|
|
257
|
-
const errors = validate.errors
|
|
258
|
-
?.map((err) => `${err.instancePath} ${err.message}`)
|
|
259
|
-
.join("; ");
|
|
260
|
-
throw new Error(
|
|
261
|
-
`Schema validation failed: ${errors}. `
|
|
262
|
-
+ `Received schema=${echo(schema)}, data=${echo(data)}`,
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
content: [
|
|
268
|
-
{ type: "text" as const, text: "Structured output recorded successfully." },
|
|
269
|
-
],
|
|
270
|
-
details: data,
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// ── Tool definition (shared between modes) ─────────────────────
|
|
275
|
-
|
|
276
|
-
export function createToolDefinition() {
|
|
277
|
-
return {
|
|
278
|
-
name: TOOL_NAME,
|
|
279
|
-
label: "Structured Output",
|
|
280
|
-
description:
|
|
281
|
-
"Return structured output validated against a JSON Schema. "
|
|
282
|
-
+ "Call this tool to produce validated JSON data. "
|
|
283
|
-
+ "Pass `schema` (a JSON Schema draft-07 object) and `data` (the value to validate). "
|
|
284
|
-
+ "When the schema is system-enforced (workflow mode), pass ONLY `data` — "
|
|
285
|
-
+ "the `schema` parameter is ignored (the system validates `data` against the authoritative schema).\n\n"
|
|
286
|
-
+ "schema describes the shape; data fills the values; they must match.\n\n"
|
|
287
|
-
+ "✅ Correct (full call): structured_output({schema:{type:'object',properties:{name:{type:'string'},age:{type:'number'}},required:['name']}, data:{name:'Alice',age:30}})\n"
|
|
288
|
-
+ "✅ Correct: schema={type:'array',items:{type:'string'}}, data=['a','b','c']\n"
|
|
289
|
-
+ "✅ Correct: schema={type:'string',enum:['low','medium','high']}, data='medium'\n"
|
|
290
|
-
+ "✅ Correct: schema={type:'number',minimum:0,maximum:100}, data=42\n"
|
|
291
|
-
+ "✅ Correct: schema={type:'boolean'}, data=true\n\n"
|
|
292
|
-
+ "❌ Wrong: putting the answer in text instead of calling this tool\n"
|
|
293
|
-
+ "❌ Wrong: data not matching schema (e.g. schema requires number but data is string)\n"
|
|
294
|
-
+ "❌ Wrong: schema={type:'object'} with data='hello' (string ≠ object)\n"
|
|
295
|
-
+ "❌ Wrong: structured_output({name:'Alice'}) — missing the schema/data envelope. Wrap as {schema:{...}, data:{name:'Alice'}}.\n"
|
|
296
|
-
+ "❌ Wrong: swapping schema and data (passing the answer as schema). The tool detects this as 'likely swapped' and rejects it.\n"
|
|
297
|
-
+ "❌ Wrong: merging schema and data into one object.\n"
|
|
298
|
-
+ "❌ Wrong: schema with no recognized JSON Schema keyword (e.g. {} or {answer:42}). The schema must describe shape via draft-07 keywords (type/properties/items/if-then-else/enum/...); a keyword-less object is rejected to prevent silent accept-all compilation.",
|
|
299
|
-
promptSnippet:
|
|
300
|
-
"Use structured-output to return validated JSON data. "
|
|
301
|
-
+ "Pass schema (JSON Schema draft-07) and data (your output). "
|
|
302
|
-
+ "Example: {schema:{type:'object',properties:{score:{type:'number'}},required:['score']}, data:{score:8}}",
|
|
303
|
-
promptGuidelines: [
|
|
304
|
-
"schema must be a valid JSON Schema (draft-07). data must conform to it.",
|
|
305
|
-
"Both primitive types (string, number, boolean) and complex types (object, array) are valid schema roots.",
|
|
306
|
-
"Do not output JSON in text — call this tool instead.",
|
|
307
|
-
],
|
|
308
|
-
parameters: Type.Object({
|
|
309
|
-
schema: Type.Unknown({
|
|
310
|
-
description: "JSON Schema draft-07 object. Example: {type:'object',properties:{name:{type:'string'}},required:['name']}",
|
|
311
|
-
}),
|
|
312
|
-
data: Type.Unknown({
|
|
313
|
-
description: "The value to validate against schema. Example: {name:'Alice'}",
|
|
314
|
-
}),
|
|
315
|
-
}),
|
|
316
|
-
async execute(
|
|
317
|
-
_toolCallId: string,
|
|
318
|
-
params: { schema: unknown; data: unknown },
|
|
319
|
-
) {
|
|
320
|
-
// workflow 模式(PI_WORKFLOW_SCHEMA 存在):权威 schema 成为唯一校验权威,
|
|
321
|
-
// LLM 传入的 params.schema 被降级为错误回显,无法影响校验结果。
|
|
322
|
-
//
|
|
323
|
-
// 运行假设:workflow 子进程是单 session 进程(由 applySchemaEnvToChildEnv 在
|
|
324
|
-
// session-runner 注入 PI_WORKFLOW_SCHEMA)。Pi extension 状态在 session_start 重建,
|
|
325
|
-
// 但 process.env 在进程级共享——这里依赖「workflow 子进程不会复用未注入 env 的 session」
|
|
326
|
-
// 的单 session 约定,故直接读 process.env 而非维护 per-session 缓存。
|
|
327
|
-
//
|
|
328
|
-
// 判空用 `|| undefined` 归一空串为 undefined(truthy 语义),与 entry 的 `if (schemaEnv)`
|
|
329
|
-
// 和 applySchemaEnvToChildEnv 的 `if (schemaEnv)` 统一:空串 env 视为未设置。
|
|
330
|
-
const authoritativeSchema = process.env[ENV_SCHEMA] || undefined;
|
|
331
|
-
return executeStructuredOutput(
|
|
332
|
-
authoritativeSchema !== undefined
|
|
333
|
-
? { ...params, authoritativeSchema }
|
|
334
|
-
: params,
|
|
335
|
-
);
|
|
336
|
-
},
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// ── Workflow hook ──────────────────────────────────────────────
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* 从 tool 执行结果里提取错误文本。
|
|
344
|
-
*
|
|
345
|
-
* Pi 框架在 tool execute 抛错时,构造 `{ content: [{ type: "text", text }] }`
|
|
346
|
-
* 塞进 result.content[0].text(见 extensions/unified-hooks 的 extractErrorText 及其
|
|
347
|
-
* 文档:SDK 事件结构里没有独立 errorMessage 字段,错误文本只能从 result.content 里取)。
|
|
348
|
-
* 这里防御性取多种结构,取不到就返回 undefined(调用方降级为通用提示)。
|
|
349
|
-
*/
|
|
350
|
-
function extractToolErrorText(result: unknown): string | undefined {
|
|
351
|
-
// 常见结构:{ content: [{ type: "text", text: "..." }] }
|
|
352
|
-
if (typeof result === "object" && result !== null) {
|
|
353
|
-
const content = (result as Record<string, unknown>).content;
|
|
354
|
-
if (Array.isArray(content)) {
|
|
355
|
-
for (const item of content) {
|
|
356
|
-
if (typeof item === "object" && item !== null) {
|
|
357
|
-
const text = (item as Record<string, unknown>).text;
|
|
358
|
-
if (typeof text === "string" && text.length > 0) return text;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
// 兜底:某些 tool 直接塞 { error: "..." }
|
|
363
|
-
const err = (result as Record<string, unknown>).error;
|
|
364
|
-
if (typeof err === "string" && err.length > 0) return err;
|
|
365
|
-
}
|
|
366
|
-
return undefined;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* 注册 turn_end hook,检查模型是否成功调用 structured-output 工具。
|
|
371
|
-
* 未成功时通过 pi.sendUserMessage({deliverAs:"steer"}) 注入 steering message 重试。
|
|
372
|
-
*
|
|
373
|
-
* 两种失败形态都会触发 steer:
|
|
374
|
-
* 1. 完全没调用(soCallCount === 0)→ 注入"必须调用"提示 + 正确 schema
|
|
375
|
-
* 2. 调了但全是 isError(soCallCount > 0 && !soSucceededEver)→ 注入具体校验错误
|
|
376
|
-
* + 正确 schema。旧实现在此处撒手交给 Pi 自然修正,但模型遇到 "Invalid JSON Schema"
|
|
377
|
-
* 时无法自行修正(它不知道正确 schema 长什么样),实测会放弃 → 子进程正常退出 →
|
|
378
|
-
* workflow 把单点失败放大成整批崩溃。故此处主动 steer 并回灌错误细节。
|
|
379
|
-
*
|
|
380
|
-
* 检测时序:Pi 保证同 turn 内所有 tool_execution_end 都在 turn_end 之前触发,
|
|
381
|
-
* 故 turn_end 读取的状态已反映本 turn 全部 tool 调用结果。
|
|
382
|
-
*/
|
|
383
|
-
function setupWorkflowHook(pi: PiAPI, schemaJson: string): void {
|
|
384
|
-
let soCallCount = 0;
|
|
385
|
-
let soSucceededEver = false;
|
|
386
|
-
let hookRetryCount = 0;
|
|
387
|
-
// 最近一次 structured-output 调用的错误文本(isError=true 时从 result.content 提取)。
|
|
388
|
-
// turn_end 据此决定 steer 消息是"必须调用"还是"修正后重试"。
|
|
389
|
-
let lastSchemaError = "";
|
|
390
|
-
|
|
391
|
-
// 追踪 structured-output 调用结果:
|
|
392
|
-
// 成功 → soSucceededEver=true(终态,后续不再干预)
|
|
393
|
-
// 失败 → soCallCount++,记录 lastSchemaError,由 turn_end 决定是否 steer 重试
|
|
394
|
-
pi.on("tool_execution_end", async (event: unknown) => {
|
|
395
|
-
if (!isToolExecutionEndEvent(event)) return;
|
|
396
|
-
if (event.toolName !== TOOL_NAME) return;
|
|
397
|
-
soCallCount++;
|
|
398
|
-
if (event.isError !== true) {
|
|
399
|
-
soSucceededEver = true;
|
|
400
|
-
} else {
|
|
401
|
-
lastSchemaError = extractToolErrorText(event.result) ?? "structured-output call failed";
|
|
402
|
-
}
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
pi.on("turn_end", async (event: unknown) => {
|
|
406
|
-
// 已经成功调用过 structured-output,不再干预
|
|
407
|
-
if (soSucceededEver) return;
|
|
408
|
-
|
|
409
|
-
// 完全没调用 OR 调了但全是失败 → 都需要 steer。两种情况共用重试上限与计数。
|
|
410
|
-
// stopReason="toolUse" → 模型还在调工具链,不需要干预
|
|
411
|
-
if (!isTurnEndEvent(event)) return;
|
|
412
|
-
if (event.message?.stopReason === "toolUse") return;
|
|
413
|
-
|
|
414
|
-
// 超过重试上限:放弃,让子进程自然结束(调用方据 result.error 判定失败)
|
|
415
|
-
if (hookRetryCount >= MAX_HOOK_RETRIES) return;
|
|
416
|
-
|
|
417
|
-
const calledButFailed = soCallCount > 0;
|
|
418
|
-
// 按本 turn 重置计数;lastSchemaError 在下次 steer 消息构造后自然覆盖
|
|
419
|
-
soCallCount = 0;
|
|
420
|
-
hookRetryCount++;
|
|
421
|
-
|
|
422
|
-
const reminder = calledButFailed
|
|
423
|
-
? [
|
|
424
|
-
"[MANDATORY] Your structured-output call FAILED validation:",
|
|
425
|
-
lastSchemaError,
|
|
426
|
-
"",
|
|
427
|
-
"The schema is enforced by the system (PI_WORKFLOW_SCHEMA) — do NOT pass your own `schema` parameter.",
|
|
428
|
-
`The required schema for your \`data\` is: ${schemaJson}`,
|
|
429
|
-
"Call the structured-output tool AGAIN with ONLY the `data` parameter conforming to this schema.",
|
|
430
|
-
"Do NOT output the result as text — call the tool.",
|
|
431
|
-
].join("\n")
|
|
432
|
-
: [
|
|
433
|
-
"[MANDATORY] You MUST call the structured-output tool now.",
|
|
434
|
-
"Your task requires a structured output. Do NOT respond with plain text.",
|
|
435
|
-
`The schema is enforced by the system. Call structured-output with ONLY \`data\` matching this shape: ${schemaJson}`,
|
|
436
|
-
"Do NOT pass a `schema` parameter — the system validates `data` against the authoritative schema automatically.",
|
|
437
|
-
"This is enforced by the workflow system. Just call the tool.",
|
|
438
|
-
].join("\n");
|
|
439
|
-
|
|
440
|
-
lastSchemaError = "";
|
|
441
|
-
pi.sendUserMessage(reminder, { deliverAs: "steer" });
|
|
442
|
-
});
|
|
443
|
-
}
|
|
27
|
+
// re-export 供测试与外部直接调用(import 路径 ../src/index.js 保持稳定)
|
|
28
|
+
export { executeStructuredOutput, createToolDefinition, RetryState };
|
|
444
29
|
|
|
445
30
|
// ── Extension entry ────────────────────────────────────────────
|
|
446
31
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema 形态守卫 — 纯逻辑叶节点(零业务依赖,全导出)。
|
|
3
|
+
*
|
|
4
|
+
* 从 index.ts 拆出:swap 检测 + silent-corruption prevention 的一组纯函数。
|
|
5
|
+
*
|
|
6
|
+
* 核心问题:schema 和 data 参数都用 Type.Unknown(),结构无差别。弱模型常把答案
|
|
7
|
+
* 塞进 schema、把形状塞进 data。因 ajv strict:false 把无 keyword 的对象编译成
|
|
8
|
+
* "接受一切" 的 validator,互换后会校验通过、存垃圾、无报错(静默腐败)。
|
|
9
|
+
* 这组守卫在编译前拦截两类形态:互换(schema 像数据 + data 像 schema)和
|
|
10
|
+
* keyword-less schema({} / {a:1} 这种会被 ajv 静默放行)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** JSON Schema draft-07 识别 keyword。只要 schema 含其一就认为是"真 schema"。 */
|
|
14
|
+
export const SCHEMA_KEYWORDS = [
|
|
15
|
+
// 核心类型
|
|
16
|
+
"type",
|
|
17
|
+
// object
|
|
18
|
+
"properties", "required", "additionalProperties", "patternProperties",
|
|
19
|
+
"minProperties", "maxProperties",
|
|
20
|
+
// array
|
|
21
|
+
"items", "additionalItems", "minItems", "maxItems", "uniqueItems",
|
|
22
|
+
// enum / const
|
|
23
|
+
"enum", "const",
|
|
24
|
+
// 组合
|
|
25
|
+
"allOf", "anyOf", "oneOf", "not",
|
|
26
|
+
// 条件验证(draft-07)
|
|
27
|
+
"if", "then", "else",
|
|
28
|
+
// 依赖与约束
|
|
29
|
+
"dependencies", "propertyNames", "contains",
|
|
30
|
+
// 引用与定义
|
|
31
|
+
"$ref", "$id", "$defs", "definitions",
|
|
32
|
+
// 数值
|
|
33
|
+
"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf",
|
|
34
|
+
// 字符串
|
|
35
|
+
"minLength", "maxLength", "pattern", "format",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function hasSchemaKeyword(obj: Record<string, unknown>): boolean {
|
|
43
|
+
return SCHEMA_KEYWORDS.some((keyword) => keyword in obj);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 错误回显长度上限(截断长 schema/data,避免错误消息爆炸)。 */
|
|
47
|
+
const ECHO_MAX_CHARS = 200;
|
|
48
|
+
|
|
49
|
+
export function echo(value: unknown): string {
|
|
50
|
+
let str: string;
|
|
51
|
+
try {
|
|
52
|
+
// JSON.stringify(undefined) 返回 undefined(不是 throw),需 ?? 兜底,
|
|
53
|
+
// 否则后续 str.length 会 "Cannot read properties of undefined"。
|
|
54
|
+
str = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
|
|
55
|
+
} catch {
|
|
56
|
+
str = String(value);
|
|
57
|
+
}
|
|
58
|
+
return str.length <= ECHO_MAX_CHARS ? str : `${str.slice(0, ECHO_MAX_CHARS)}...`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 尝试 JSON.parse;失败(malformed JSON)时保留原值,让 Ajv 拒绝。
|
|
63
|
+
* 模型有时把 schema/data 当 JSON 字符串传;parse 失败不是错误,保持原样让下游校验拒绝。
|
|
64
|
+
* catch 里有实质处理(决定返回原值),满足 taste/no-silent-catch。
|
|
65
|
+
*/
|
|
66
|
+
export function tryParseJson(raw: unknown): unknown {
|
|
67
|
+
if (typeof raw !== "string") return raw;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
return raw; // malformed JSON → 保留原字符串,Ajv 会拒绝
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 把 authoritative schema 的 unknown 收窄为合法 JSON Schema 根类型(object | boolean)。
|
|
77
|
+
* draft-07 允许 boolean 根 schema(true=接受一切,false=拒绝一切)。
|
|
78
|
+
* 独立守卫使后续 getOrCompileValidator(authoritative) 在类型层面也成立,
|
|
79
|
+
* 避免在守卫块外直接用 unknown。非合法形态抛清晰错误。
|
|
80
|
+
*/
|
|
81
|
+
export function assertJsonSchemaRoot(value: unknown): asserts value is Record<string, unknown> | boolean {
|
|
82
|
+
if (!(isPlainObject(value) || typeof value === "boolean")) {
|
|
83
|
+
throw new Error(`authoritative schema must be a JSON Schema object or boolean, got ${typeof value}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** turn_end event 是否可安全访问 message.stopReason(用于判断模型是否还在调工具链)。 */
|
|
88
|
+
export function isTurnEndEvent(e: unknown): e is { message?: { stopReason?: string } } {
|
|
89
|
+
return typeof e === "object" && e !== null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** tool_execution_end event 结构守卫(替代直接 cast,配合 taste/no-unsafe-cast)。 */
|
|
93
|
+
export function isToolExecutionEndEvent(
|
|
94
|
+
e: unknown,
|
|
95
|
+
): e is { toolName: unknown; isError: unknown; result?: unknown } {
|
|
96
|
+
return typeof e === "object" && e !== null && "toolName" in e && "isError" in e;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** swap 检测 + keyword-less schema 拒绝的纠错文案前缀,所有相关错误共用。 */
|
|
100
|
+
export const CORRECT_USAGE_HINT =
|
|
101
|
+
"Correct: structured_output({schema:{type:'object',properties:{...}}, data:{...actual values}}). ";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* structured-output tool 定义(两种模式共享)。
|
|
3
|
+
*
|
|
4
|
+
* env 桥接:execute 内读 process.env[PI_WORKFLOW_SCHEMA],存在时注入
|
|
5
|
+
* authoritativeSchema(workflow 模式权威校验),否则走日常防御链。
|
|
6
|
+
* description/promptSnippet/promptGuidelines 文本被 prompt-quality.test.ts
|
|
7
|
+
* 文本断言锁定,逐字保留。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
|
|
12
|
+
import { executeStructuredOutput } from "./execute.js";
|
|
13
|
+
|
|
14
|
+
export const TOOL_NAME = "structured-output";
|
|
15
|
+
export const ENV_SCHEMA = "PI_WORKFLOW_SCHEMA";
|
|
16
|
+
|
|
17
|
+
// ── Tool definition (shared between modes) ─────────────────────
|
|
18
|
+
|
|
19
|
+
export function createToolDefinition() {
|
|
20
|
+
return {
|
|
21
|
+
name: TOOL_NAME,
|
|
22
|
+
label: "Structured Output",
|
|
23
|
+
description:
|
|
24
|
+
"Return structured output validated against a JSON Schema. "
|
|
25
|
+
+ "Call this tool to produce validated JSON data. "
|
|
26
|
+
+ "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
|
+
+ "schema describes the shape; data fills the values; they must match.\n\n"
|
|
30
|
+
+ "✅ Correct (full call): structured_output({schema:{type:'object',properties:{name:{type:'string'},age:{type:'number'}},required:['name']}, data:{name:'Alice',age:30}})\n"
|
|
31
|
+
+ "✅ Correct: schema={type:'array',items:{type:'string'}}, data=['a','b','c']\n"
|
|
32
|
+
+ "✅ Correct: schema={type:'string',enum:['low','medium','high']}, data='medium'\n"
|
|
33
|
+
+ "✅ Correct: schema={type:'number',minimum:0,maximum:100}, data=42\n"
|
|
34
|
+
+ "✅ Correct: schema={type:'boolean'}, data=true\n\n"
|
|
35
|
+
+ "❌ Wrong: putting the answer in text instead of calling this tool\n"
|
|
36
|
+
+ "❌ Wrong: data not matching schema (e.g. schema requires number but data is string)\n"
|
|
37
|
+
+ "❌ Wrong: schema={type:'object'} with data='hello' (string ≠ object)\n"
|
|
38
|
+
+ "❌ Wrong: structured_output({name:'Alice'}) — missing the schema/data envelope. Wrap as {schema:{...}, data:{name:'Alice'}}.\n"
|
|
39
|
+
+ "❌ Wrong: swapping schema and data (passing the answer as schema). The tool detects this as 'likely swapped' and rejects it.\n"
|
|
40
|
+
+ "❌ Wrong: merging schema and data into one object.\n"
|
|
41
|
+
+ "❌ Wrong: schema with no recognized JSON Schema keyword (e.g. {} or {answer:42}). The schema must describe shape via draft-07 keywords (type/properties/items/if-then-else/enum/...); a keyword-less object is rejected to prevent silent accept-all compilation.",
|
|
42
|
+
promptSnippet:
|
|
43
|
+
"Use structured-output to return validated JSON data. "
|
|
44
|
+
+ "Pass schema (JSON Schema draft-07) and data (your output). "
|
|
45
|
+
+ "Example: {schema:{type:'object',properties:{score:{type:'number'}},required:['score']}, data:{score:8}}",
|
|
46
|
+
promptGuidelines: [
|
|
47
|
+
"schema must be a valid JSON Schema (draft-07). data must conform to it.",
|
|
48
|
+
"Both primitive types (string, number, boolean) and complex types (object, array) are valid schema roots.",
|
|
49
|
+
"Do not output JSON in text — call this tool instead.",
|
|
50
|
+
],
|
|
51
|
+
parameters: Type.Object({
|
|
52
|
+
schema: Type.Unknown({
|
|
53
|
+
description: "JSON Schema draft-07 object. Example: {type:'object',properties:{name:{type:'string'}},required:['name']}",
|
|
54
|
+
}),
|
|
55
|
+
data: Type.Unknown({
|
|
56
|
+
description: "The value to validate against schema. Example: {name:'Alice'}",
|
|
57
|
+
}),
|
|
58
|
+
}),
|
|
59
|
+
async execute(
|
|
60
|
+
_toolCallId: string,
|
|
61
|
+
params: { schema: unknown; data: unknown },
|
|
62
|
+
) {
|
|
63
|
+
// workflow 模式(PI_WORKFLOW_SCHEMA 存在):权威 schema 成为唯一校验权威,
|
|
64
|
+
// LLM 传入的 params.schema 被降级为错误回显,无法影响校验结果。
|
|
65
|
+
//
|
|
66
|
+
// 运行假设:workflow 子进程是单 session 进程(由 applySchemaEnvToChildEnv 在
|
|
67
|
+
// session-runner 注入 PI_WORKFLOW_SCHEMA)。Pi extension 状态在 session_start 重建,
|
|
68
|
+
// 但 process.env 在进程级共享——这里依赖「workflow 子进程不会复用未注入 env 的 session」
|
|
69
|
+
// 的单 session 约定,故直接读 process.env 而非维护 per-session 缓存。
|
|
70
|
+
//
|
|
71
|
+
// 判空用 `|| undefined` 归一空串为 undefined(truthy 语义),与 entry 的 `if (schemaEnv)`
|
|
72
|
+
// 和 applySchemaEnvToChildEnv 的 `if (schemaEnv)` 统一:空串 env 视为未设置。
|
|
73
|
+
const authoritativeSchema = process.env[ENV_SCHEMA] || undefined;
|
|
74
|
+
return executeStructuredOutput(
|
|
75
|
+
authoritativeSchema !== undefined
|
|
76
|
+
? { ...params, authoritativeSchema }
|
|
77
|
+
: params,
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow hook:turn_end 时检查模型是否成功调用 structured-output 工具。
|
|
3
|
+
* 未成功时通过 pi.sendUserMessage({deliverAs:"steer"}) 注入 steering message 重试。
|
|
4
|
+
* 最多重试 MAX_HOOK_RETRIES 次,防止无限循环。
|
|
5
|
+
*
|
|
6
|
+
* RetryState:从旧 4 个 mutable 闭包(soCallCount/soSucceededEver/hookRetryCount/
|
|
7
|
+
* lastSchemaError)显式化为类——per-turn reset 时机成为可单测的显式契约:
|
|
8
|
+
* onTurnEnd() 仅在「判定要 steer」时调用(守卫链 toolUse/超上限/成功短路均不调)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
import { isToolExecutionEndEvent, isTurnEndEvent } from "./schema-guards.js";
|
|
14
|
+
|
|
15
|
+
/** Pi Extension API — properly typed via ExtensionAPI from pi-coding-agent SDK */
|
|
16
|
+
type PiAPI = ExtensionAPI;
|
|
17
|
+
|
|
18
|
+
// 与 tool-definition.ts 的 TOOL_NAME 对应(本模块只监听该工具的 execution 事件;
|
|
19
|
+
// 保持依赖图单向:workflow-hook → schema-guards,不 import tool-definition)。
|
|
20
|
+
const TOOL_NAME = "structured-output";
|
|
21
|
+
const MAX_HOOK_RETRIES = 2;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* structured-output 调用状态机(IF-7,export 契约——M5 从本模块 import)。
|
|
25
|
+
*
|
|
26
|
+
* 转移表(M4-TC-2 单测锁定):
|
|
27
|
+
* - onToolExecEnd(false) → soCallCount++ / soSucceededEver=true(成功短路终态)
|
|
28
|
+
* - onToolExecEnd(true, err) → soCallCount++ / lastSchemaError=err ?? 通用提示
|
|
29
|
+
* - onTurnEnd() → soCallCount=0 / hookRetryCount++ / lastSchemaError=null
|
|
30
|
+
* (仅当 workflow-hook 判定要 steer 时调用——toolUse/超上限/成功短路均不调,
|
|
31
|
+
* 故 toolUse 保留 soCallCount、超上限保留 lastSchemaError,与旧 4-closure 逐点一致)
|
|
32
|
+
*/
|
|
33
|
+
export class RetryState {
|
|
34
|
+
soCallCount = 0;
|
|
35
|
+
soSucceededEver = false;
|
|
36
|
+
hookRetryCount = 0;
|
|
37
|
+
lastSchemaError: string | null = null;
|
|
38
|
+
|
|
39
|
+
/** 记录一次 structured-output tool 执行结果。hasError = event.isError === true。 */
|
|
40
|
+
onToolExecEnd(hasError: boolean, errorMsg?: string): { shouldSteer: boolean } {
|
|
41
|
+
this.soCallCount++;
|
|
42
|
+
if (!hasError) {
|
|
43
|
+
this.soSucceededEver = true;
|
|
44
|
+
return { shouldSteer: false };
|
|
45
|
+
}
|
|
46
|
+
this.lastSchemaError = errorMsg ?? "structured-output call failed";
|
|
47
|
+
return { shouldSteer: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** turn 收尾(仅当要 steer 时调用):重置本 turn 计数、累计重试次数、清空错误。 */
|
|
51
|
+
onTurnEnd(): void {
|
|
52
|
+
this.soCallCount = 0;
|
|
53
|
+
this.hookRetryCount++;
|
|
54
|
+
this.lastSchemaError = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 四字段归零(当前无调用方;保留作状态机完整契约)。 */
|
|
58
|
+
reset(): void {
|
|
59
|
+
this.soCallCount = 0;
|
|
60
|
+
this.soSucceededEver = false;
|
|
61
|
+
this.hookRetryCount = 0;
|
|
62
|
+
this.lastSchemaError = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 从 tool 执行结果里提取错误文本。
|
|
68
|
+
*
|
|
69
|
+
* Pi 框架在 tool execute 抛错时,构造 `{ content: [{ type: "text", text }] }`
|
|
70
|
+
* 塞进 result.content[0].text(见 extensions/unified-hooks 的 extractErrorText 及其
|
|
71
|
+
* 文档:SDK 事件结构里没有独立 errorMessage 字段,错误文本只能从 result.content 里取)。
|
|
72
|
+
* 这里防御性取多种结构,取不到就返回 undefined(调用方降级为通用提示)。
|
|
73
|
+
*/
|
|
74
|
+
function extractToolErrorText(result: unknown): string | undefined {
|
|
75
|
+
// 常见结构:{ content: [{ type: "text", text: "..." }] }
|
|
76
|
+
if (typeof result === "object" && result !== null) {
|
|
77
|
+
const content = (result as Record<string, unknown>).content;
|
|
78
|
+
if (Array.isArray(content)) {
|
|
79
|
+
for (const item of content) {
|
|
80
|
+
if (typeof item === "object" && item !== null) {
|
|
81
|
+
const text = (item as Record<string, unknown>).text;
|
|
82
|
+
if (typeof text === "string" && text.length > 0) return text;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// 兜底:某些 tool 直接塞 { error: "..." }
|
|
87
|
+
const err = (result as Record<string, unknown>).error;
|
|
88
|
+
if (typeof err === "string" && err.length > 0) return err;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 注册 turn_end hook,检查模型是否成功调用 structured-output 工具。
|
|
95
|
+
* 未成功时通过 pi.sendUserMessage({deliverAs:"steer"}) 注入 steering message 重试。
|
|
96
|
+
*
|
|
97
|
+
* 两种失败形态都会触发 steer:
|
|
98
|
+
* 1. 完全没调用(soCallCount === 0)→ 注入"必须调用"提示 + 正确 schema
|
|
99
|
+
* 2. 调了但全是 isError(soCallCount > 0 && !soSucceededEver)→ 注入具体校验错误
|
|
100
|
+
* + 正确 schema。旧实现在此处撒手交给 Pi 自然修正,但模型遇到 "Invalid JSON Schema"
|
|
101
|
+
* 时无法自行修正(它不知道正确 schema 长什么样),实测会放弃 → 子进程正常退出 →
|
|
102
|
+
* workflow 把单点失败放大成整批崩溃。故此处主动 steer 并回灌错误细节。
|
|
103
|
+
*
|
|
104
|
+
* 检测时序:Pi 保证同 turn 内所有 tool_execution_end 都在 turn_end 之前触发,
|
|
105
|
+
* 故 turn_end 读取的状态已反映本 turn 全部 tool 调用结果。
|
|
106
|
+
*/
|
|
107
|
+
export function setupWorkflowHook(pi: PiAPI, schemaJson: string): void {
|
|
108
|
+
const state = new RetryState();
|
|
109
|
+
|
|
110
|
+
// 追踪 structured-output 调用结果:
|
|
111
|
+
// 成功 → soSucceededEver=true(终态,后续不再干预)
|
|
112
|
+
// 失败 → soCallCount++,记录 lastSchemaError,由 turn_end 决定是否 steer 重试
|
|
113
|
+
pi.on("tool_execution_end", async (event: unknown) => {
|
|
114
|
+
if (!isToolExecutionEndEvent(event)) return;
|
|
115
|
+
if (event.toolName !== TOOL_NAME) return;
|
|
116
|
+
state.onToolExecEnd(
|
|
117
|
+
event.isError === true,
|
|
118
|
+
extractToolErrorText(event.result) ?? "structured-output call failed",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
pi.on("turn_end", async (event: unknown) => {
|
|
123
|
+
// 守卫链:以下情况均直接 return(不调 onTurnEnd——toolUse 保留 soCallCount、
|
|
124
|
+
// 超上限保留 lastSchemaError,与旧 4-closure 逐点一致):
|
|
125
|
+
// 1. 已经成功调用过 structured-output,不再干预
|
|
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;
|
|
133
|
+
|
|
134
|
+
// 完全没调用 OR 调了但全是失败 → 都需要 steer。两种情况共用重试上限与计数。
|
|
135
|
+
const calledButFailed = state.soCallCount > 0;
|
|
136
|
+
// 构造 reminder 时 lastSchemaError 必须仍是本 turn 的错误文本,
|
|
137
|
+
// 故 onTurnEnd()(清空 lastSchemaError)必须在 reminder 构造之后调用。
|
|
138
|
+
const reminder = calledButFailed
|
|
139
|
+
? [
|
|
140
|
+
"[MANDATORY] Your structured-output call FAILED validation:",
|
|
141
|
+
state.lastSchemaError ?? "structured-output call failed",
|
|
142
|
+
"",
|
|
143
|
+
"The schema is enforced by the system (PI_WORKFLOW_SCHEMA) — do NOT pass your own `schema` parameter.",
|
|
144
|
+
`The required schema for your \`data\` is: ${schemaJson}`,
|
|
145
|
+
"Call the structured-output tool AGAIN with ONLY the `data` parameter conforming to this schema.",
|
|
146
|
+
"Do NOT output the result as text — call the tool.",
|
|
147
|
+
].join("\n")
|
|
148
|
+
: [
|
|
149
|
+
"[MANDATORY] You MUST call the structured-output tool now.",
|
|
150
|
+
"Your task requires a structured output. Do NOT respond with plain text.",
|
|
151
|
+
`The schema is enforced by the system. Call structured-output with ONLY \`data\` matching this shape: ${schemaJson}`,
|
|
152
|
+
"Do NOT pass a `schema` parameter — the system validates `data` against the authoritative schema automatically.",
|
|
153
|
+
"This is enforced by the workflow system. Just call the tool.",
|
|
154
|
+
].join("\n");
|
|
155
|
+
|
|
156
|
+
// 按本 turn 重置计数、累计重试次数、清空 lastSchemaError(steer 后本 turn 状态归零)
|
|
157
|
+
state.onTurnEnd();
|
|
158
|
+
pi.sendUserMessage(reminder, { deliverAs: "steer" });
|
|
159
|
+
});
|
|
160
|
+
}
|