@zhushanwen/pi-structured-output 5.1.4 → 5.1.6
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 +3 -2
- package/src/execute.ts +7 -6
- package/src/loop-gate.ts +34 -57
- package/src/schema-guards.ts +9 -4
- package/src/text-primitives.ts +3 -4
- package/src/tool-definition.ts +2 -2
- package/src/workflow-hook.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-structured-output",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.6",
|
|
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",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"index.ts"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"ajv": "^8.17.0"
|
|
27
|
+
"ajv": "^8.17.0",
|
|
28
|
+
"@zhushanwen/pi-ext-guards": "0.3.0"
|
|
28
29
|
},
|
|
29
30
|
"peerDependencies": {
|
|
30
31
|
"@earendil-works/pi-coding-agent": "^0.84.4",
|
package/src/execute.ts
CHANGED
|
@@ -20,12 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
import type { ValidateFunction } from "ajv";
|
|
22
22
|
|
|
23
|
+
import { isRecord } from "@zhushanwen/pi-ext-guards";
|
|
24
|
+
|
|
23
25
|
import { getOrCompileValidator } from "./ajv-validator.js";
|
|
24
26
|
import {
|
|
25
27
|
CORRECT_USAGE_HINT,
|
|
26
28
|
echo,
|
|
27
29
|
hasSchemaKeyword,
|
|
28
|
-
isPlainObject,
|
|
29
30
|
tryParseJson,
|
|
30
31
|
} from "./schema-guards.js";
|
|
31
32
|
|
|
@@ -47,7 +48,7 @@ import {
|
|
|
47
48
|
* 其 ASP 文案按本判定同源条件化——改动本函数判定逻辑必须同步该副本。
|
|
48
49
|
*/
|
|
49
50
|
export function isObjectRootSchema(schema: unknown): schema is Record<string, unknown> {
|
|
50
|
-
if (!
|
|
51
|
+
if (!isRecord(schema)) return false;
|
|
51
52
|
if (schema.type === "object") return true;
|
|
52
53
|
if (Array.isArray(schema.type) && schema.type.includes("object")) return true;
|
|
53
54
|
const OBJECT_ONLY_KEYS = [
|
|
@@ -71,7 +72,7 @@ export function isObjectRootSchema(schema: unknown): schema is Record<string, un
|
|
|
71
72
|
* 不在 execute 层制造第二道校验。
|
|
72
73
|
*/
|
|
73
74
|
function unwrapValueField(data: unknown): unknown {
|
|
74
|
-
if (
|
|
75
|
+
if (isRecord(data) && "value" in data) {
|
|
75
76
|
return data.value;
|
|
76
77
|
}
|
|
77
78
|
return data;
|
|
@@ -88,7 +89,7 @@ export function validateAgainstSelfReported(schema: unknown, data: unknown): boo
|
|
|
88
89
|
// 1. 互换检测:schema 像数据(对象无 keyword)且 data 像 schema(对象有 keyword)。
|
|
89
90
|
// 这是最严重的静默腐败路径——若放行,ajv 会把"数据形态的 schema"编译成接受一切,
|
|
90
91
|
// 真正的 schema(此时在 data 里)被丢弃,校验通过并存入垃圾。
|
|
91
|
-
if (
|
|
92
|
+
if (isRecord(schema) && !hasSchemaKeyword(schema) && isRecord(data) && hasSchemaKeyword(data)) {
|
|
92
93
|
throw new Error(
|
|
93
94
|
"Likely swapped: schema looks like data and data looks like a schema. "
|
|
94
95
|
+ CORRECT_USAGE_HINT
|
|
@@ -98,7 +99,7 @@ export function validateAgainstSelfReported(schema: unknown, data: unknown): boo
|
|
|
98
99
|
|
|
99
100
|
// 2. keyword-less schema 拒绝:治静默腐败的根。{} / {a:1} 这类对象会被
|
|
100
101
|
// ajv strict:false 编译成"接受一切"的 validator,模型把答案塞进 schema 时会静默通过。
|
|
101
|
-
if (
|
|
102
|
+
if (isRecord(schema) && !hasSchemaKeyword(schema)) {
|
|
102
103
|
throw new Error(
|
|
103
104
|
"Invalid JSON Schema: schema has no recognized keyword "
|
|
104
105
|
+ "(type/properties/items/enum/...). If you passed the answer value as schema, "
|
|
@@ -113,7 +114,7 @@ export function validateAgainstSelfReported(schema: unknown, data: unknown): boo
|
|
|
113
114
|
// object|boolean,消除原先的 `as Record<string,unknown>` 不安全 cast。
|
|
114
115
|
let validate: ValidateFunction;
|
|
115
116
|
try {
|
|
116
|
-
if (
|
|
117
|
+
if (isRecord(schema) || typeof schema === "boolean") {
|
|
117
118
|
validate = getOrCompileValidator(schema);
|
|
118
119
|
} else {
|
|
119
120
|
throw new Error(`schema must be a JSON Schema object or boolean, got ${typeof schema}`);
|
package/src/loop-gate.ts
CHANGED
|
@@ -41,8 +41,9 @@
|
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
43
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
44
|
+
import { guardStaleCtx, isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
44
45
|
|
|
45
|
-
import {
|
|
46
|
+
import { isToolExecutionEndEvent } from "./schema-guards.js";
|
|
46
47
|
import {
|
|
47
48
|
extractToolErrorText,
|
|
48
49
|
SIGNATURE_MAX_CHARS,
|
|
@@ -247,10 +248,10 @@ function keysAtPath(args: Record<string, unknown>, path: string): string[] {
|
|
|
247
248
|
current = current[index];
|
|
248
249
|
continue;
|
|
249
250
|
}
|
|
250
|
-
if (!
|
|
251
|
+
if (!isRecord(current) || !(segment in current)) return [];
|
|
251
252
|
current = current[segment];
|
|
252
253
|
}
|
|
253
|
-
return
|
|
254
|
+
return isRecord(current) ? Object.keys(current) : [];
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
/**
|
|
@@ -282,7 +283,7 @@ function parseArgsEchoObject(errorText: string, markerIdx: number): Record<strin
|
|
|
282
283
|
if (!echoSection) return undefined;
|
|
283
284
|
try {
|
|
284
285
|
const parsed: unknown = JSON.parse(echoSection);
|
|
285
|
-
return
|
|
286
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
286
287
|
} catch {
|
|
287
288
|
return undefined;
|
|
288
289
|
}
|
|
@@ -391,36 +392,9 @@ export class LoopGate {
|
|
|
391
392
|
/** terminal 日志的 appendEntry customType(session.jsonl 持久化,不进 LLM 上下文)。 */
|
|
392
393
|
export const GATE_ENTRY_TYPE = "structured-output:gate";
|
|
393
394
|
|
|
394
|
-
/**
|
|
395
|
-
* setTimeout delay 安全域校验。[同源锚定] @zhushanwen/pi-subagent-workflow 的
|
|
396
|
-
* shared/timer-delay.ts assertSafeTimerDelay 本地副本——两包独立 npm 不能直接
|
|
397
|
-
* import(isObjectRootSchema 本地副本同例:跨包相对 import 在发布产物里悬空),
|
|
398
|
-
* 本包仅此一个 timer 入口,取最小面副本。语义同源:非有限值 / 超 2^31-1 的 delay
|
|
399
|
-
* 会被 Node 塌缩为 1ms 立即触发(语义反转:兜底窗口变成立即硬杀),fail-fast 不
|
|
400
|
-
* 静默 clamp(clamp 把配置错误变成静默语义漂移)。
|
|
401
|
-
*/
|
|
402
|
-
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
403
|
-
|
|
404
395
|
/** 每秒毫秒数(teardown 日志 ms→s 换算用,no-magic-numbers)。 */
|
|
405
396
|
const MS_PER_SECOND = 1000;
|
|
406
397
|
|
|
407
|
-
export function assertSafeTimerDelay(ms: number, source: string): void {
|
|
408
|
-
if (!Number.isFinite(ms)) {
|
|
409
|
-
throw new Error(
|
|
410
|
-
`[structured-output] ${source} = ${ms} is not a finite number (NaN/±Infinity). `
|
|
411
|
-
+ "Non-finite delays collapse to 1ms in Node setTimeout and fire immediately. "
|
|
412
|
-
+ "Recovery: fix the constant/computation feeding this timer and retry.",
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
if (ms > MAX_TIMER_DELAY_MS) {
|
|
416
|
-
throw new Error(
|
|
417
|
-
`[structured-output] ${source} = ${ms} exceeds the Node setTimeout limit `
|
|
418
|
-
+ `(${MAX_TIMER_DELAY_MS} ms = 2^31-1); larger delays silently collapse to 1ms and fire immediately. `
|
|
419
|
-
+ `Recovery: clamp the value to <= ${MAX_TIMER_DELAY_MS} and retry.`,
|
|
420
|
-
);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
398
|
/**
|
|
425
399
|
* terminal 后 bounded teardown 的兜底硬退窗口(ms)。
|
|
426
400
|
*
|
|
@@ -434,6 +408,10 @@ export function assertSafeTimerDelay(ms: number, source: string): void {
|
|
|
434
408
|
* 后正常退出秒级完成),兜底只覆盖「pi 挂死不 settle」的异常态——此时宁可硬退
|
|
435
409
|
*(父进程 SW 侧走「子进程结束未产出 structured-output」失败路径,stderr 已留原因)
|
|
436
410
|
* 也不无限烧 token。
|
|
411
|
+
*
|
|
412
|
+
* 安全域:15_000 为字面量常量,处于 Node setTimeout 安全域内(非有限/超 2^31-1
|
|
413
|
+
* 会塌缩为 1ms 立即触发——若未来 delay 来源动态化,需引入安全域校验,参照
|
|
414
|
+
* packages/subagent-core/src/shared/timer-delay.ts 的 assertSafeTimerDelay)。
|
|
437
415
|
*/
|
|
438
416
|
export const TEARDOWN_FORCE_EXIT_MS = 15_000;
|
|
439
417
|
|
|
@@ -443,24 +421,10 @@ export const TEARDOWN_FORCE_EXIT_MS = 15_000;
|
|
|
443
421
|
*/
|
|
444
422
|
const TEARDOWN_EXIT_CODE = 1;
|
|
445
423
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
type TeardownTimerSlot = { current: ReturnType<typeof setTimeout> | undefined };
|
|
452
|
-
|
|
453
|
-
function getTeardownTimerSlot(): TeardownTimerSlot {
|
|
454
|
-
// globalThis 无 symbol 索引签名,但运行时支持 symbol 键——用 Reflect 安全读写
|
|
455
|
-
//(notify-ledger getNotifyLedgerSlot 同款)。TeardownTimerSlot 是运行时保证的
|
|
456
|
-
// 固定形状(本文件唯一写入点)。
|
|
457
|
-
let slot = Reflect.get(globalThis, TEARDOWN_TIMER_SLOT_KEY) as TeardownTimerSlot | undefined;
|
|
458
|
-
if (!slot) {
|
|
459
|
-
slot = { current: undefined };
|
|
460
|
-
Reflect.set(globalThis, TEARDOWN_TIMER_SLOT_KEY, slot);
|
|
461
|
-
}
|
|
462
|
-
return slot;
|
|
463
|
-
}
|
|
424
|
+
// one-shot timer 不属 C-ext-06 §7.5 的「跨 session 存活进程级单例」范畴:
|
|
425
|
+
// terminal 一次性武装、随 process.exit 消亡;jiti 双实例下双 timer 各自
|
|
426
|
+
// process.exit 进程级幂等,模块级 let 即可。
|
|
427
|
+
let teardownTimer: ReturnType<typeof setTimeout> | undefined;
|
|
464
428
|
|
|
465
429
|
/**
|
|
466
430
|
* 武装 terminal 后的 bounded teardown 兜底:TEARDOWN_FORCE_EXIT_MS 后进程仍未退出
|
|
@@ -474,14 +438,12 @@ function getTeardownTimerSlot(): TeardownTimerSlot {
|
|
|
474
438
|
* 能做的最大硬杀就是对自身 process.exit。故采用任务预设的兜底形态:abort+shutdown
|
|
475
439
|
* 优雅退出为主,定时 process.exit 兜底。
|
|
476
440
|
*
|
|
477
|
-
* timer 卫生:
|
|
441
|
+
* timer 卫生:delay 为字面量常量、处 setTimeout 安全域(见 TEARDOWN_FORCE_EXIT_MS 注释)+
|
|
478
442
|
* unref(不阻止 pi 在窗口内自然退出;自然退出时本 timer 随进程消亡不再开火)+
|
|
479
443
|
* terminal 路径幂等 clearTimeout(重复武装不叠加多个兜底 timer)。
|
|
480
444
|
*/
|
|
481
445
|
export function armForceExitTeardown(): void {
|
|
482
|
-
|
|
483
|
-
if (slot.current !== undefined) clearTimeout(slot.current);
|
|
484
|
-
assertSafeTimerDelay(TEARDOWN_FORCE_EXIT_MS, "structured-output gate teardown");
|
|
446
|
+
if (teardownTimer !== undefined) clearTimeout(teardownTimer);
|
|
485
447
|
const timer = setTimeout(() => {
|
|
486
448
|
process.stderr.write(
|
|
487
449
|
`[structured-output gate] graceful shutdown did not complete within ${TEARDOWN_FORCE_EXIT_MS / MS_PER_SECOND}s; `
|
|
@@ -491,7 +453,7 @@ export function armForceExitTeardown(): void {
|
|
|
491
453
|
}, TEARDOWN_FORCE_EXIT_MS);
|
|
492
454
|
// unref:窗口内 pi 自然退出时不被本 timer 拖住(timer 随进程消亡,不再开火)
|
|
493
455
|
timer.unref();
|
|
494
|
-
|
|
456
|
+
teardownTimer = timer;
|
|
495
457
|
}
|
|
496
458
|
|
|
497
459
|
/**
|
|
@@ -522,7 +484,7 @@ function writeTerminatedLog(pi: PiAPI, gate: LoopGate): void {
|
|
|
522
484
|
} catch (err) {
|
|
523
485
|
// appendEntry 失败不阻断 shutdown——stderr 通道已落,此处补诊断(同 cache-probe 惯例)
|
|
524
486
|
process.stderr.write(
|
|
525
|
-
`[structured-output gate] appendEntry failed: ${
|
|
487
|
+
`[structured-output gate] appendEntry failed: ${toErrorMessage(err)}\n`,
|
|
526
488
|
);
|
|
527
489
|
}
|
|
528
490
|
}
|
|
@@ -558,8 +520,23 @@ export function setupLoopGate(pi: PiAPI, options: LoopGateOptions = {}): LoopGat
|
|
|
558
520
|
|
|
559
521
|
options.onTerminal?.();
|
|
560
522
|
writeTerminatedLog(pi, gate);
|
|
561
|
-
ctx
|
|
562
|
-
|
|
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
|
+
});
|
|
563
540
|
armForceExitTeardown();
|
|
564
541
|
});
|
|
565
542
|
|
package/src/schema-guards.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* keyword-less schema({} / {a:1} 这种会被 ajv 静默放行)。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { isRecord } from "@zhushanwen/pi-ext-guards";
|
|
14
|
+
|
|
13
15
|
/** JSON Schema draft-07 识别 keyword。只要 schema 含其一就认为是"真 schema"。 */
|
|
14
16
|
export const SCHEMA_KEYWORDS = [
|
|
15
17
|
// 核心类型
|
|
@@ -35,9 +37,12 @@ export const SCHEMA_KEYWORDS = [
|
|
|
35
37
|
"minLength", "maxLength", "pattern", "format",
|
|
36
38
|
] as const;
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* @deprecated 深路径公开名兼容别名(ext-simplify-18 D2);新代码直接用 ext-guards isRecord。
|
|
42
|
+
* 本包独立发布、无 exports 字段且 files 含 src/(深路径对外可达),公开名直接删除是
|
|
43
|
+
* breaking——保留一个发布周期的 re-export,删除时点登记在 ext-simplify index 18 号行。
|
|
44
|
+
*/
|
|
45
|
+
export const isPlainObject = isRecord;
|
|
41
46
|
|
|
42
47
|
export function hasSchemaKeyword(obj: Record<string, unknown>): boolean {
|
|
43
48
|
return SCHEMA_KEYWORDS.some((keyword) => keyword in obj);
|
|
@@ -79,7 +84,7 @@ export function tryParseJson(raw: unknown): unknown {
|
|
|
79
84
|
* 避免在守卫块外直接用 unknown。非合法形态抛清晰错误。
|
|
80
85
|
*/
|
|
81
86
|
export function assertJsonSchemaRoot(value: unknown): asserts value is Record<string, unknown> | boolean {
|
|
82
|
-
if (!(
|
|
87
|
+
if (!(isRecord(value) || typeof value === "boolean")) {
|
|
83
88
|
throw new Error(`authoritative schema must be a JSON Schema object or boolean, got ${typeof value}`);
|
|
84
89
|
}
|
|
85
90
|
}
|
package/src/text-primitives.ts
CHANGED
|
@@ -40,10 +40,9 @@ export function truncateText(text: string, max: number): string {
|
|
|
40
40
|
*
|
|
41
41
|
* Pi 框架在参数层校验失败(immediate 路径)与 execute 抛错时,均构造
|
|
42
42
|
* `{ content: [{ type: "text", text }] }` 塞进 result.content[0].text
|
|
43
|
-
* (agent-loop.js createErrorToolResult
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* steer 回灌文本。
|
|
43
|
+
* (agent-loop.js createErrorToolResult;SDK 事件结构里没有独立 errorMessage 字段,
|
|
44
|
+
* 错误文本只能从 result.content 里取)。loop-gate(D3)复用本函数提取签名原料,
|
|
45
|
+
* workflow-hook 取 steer 回灌文本。
|
|
47
46
|
* 这里防御性取多种结构,取不到就返回 undefined(调用方降级为通用提示)。
|
|
48
47
|
*/
|
|
49
48
|
export function extractToolErrorText(result: unknown): string | undefined {
|
package/src/tool-definition.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* 两变体的描述文本均被 prompt-quality.test.ts 文本断言锁定。
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { isRecord } from "@zhushanwen/pi-ext-guards";
|
|
21
22
|
import { Type } from "typebox";
|
|
22
23
|
|
|
23
24
|
import { executeStructuredOutput, isObjectRootSchema } from "./execute.js";
|
|
@@ -25,7 +26,6 @@ import {
|
|
|
25
26
|
assertJsonSchemaRoot,
|
|
26
27
|
echo,
|
|
27
28
|
hasSchemaKeyword,
|
|
28
|
-
isPlainObject,
|
|
29
29
|
tryParseJson,
|
|
30
30
|
} from "./schema-guards.js";
|
|
31
31
|
|
|
@@ -93,7 +93,7 @@ export function createWorkflowToolDefinition(envSchema: string) {
|
|
|
93
93
|
);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
if (
|
|
96
|
+
if (isRecord(schema) && !hasSchemaKeyword(schema)) {
|
|
97
97
|
// ERR-3 上移:keyword-less 对象会被编译成 accept-all,workflow 声明的约束静默失效。
|
|
98
98
|
throw new Error(
|
|
99
99
|
"Authoritative schema (PI_WORKFLOW_SCHEMA) has no recognized keyword. "
|
package/src/workflow-hook.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
12
13
|
|
|
13
14
|
// 截断原语与错误块预算来自 text-primitives(共享叶节点,导出复用勿复制)。原
|
|
14
15
|
// 「反向依赖(环)」已破除:本模块不再 import loop-gate,依赖图单向
|
|
@@ -95,7 +96,7 @@ export const HOOK_ENTRY_TYPE = "structured-output:hook";
|
|
|
95
96
|
* 下一个正常收尾的轮仍会重试 steer,不产生静默哑火。
|
|
96
97
|
*/
|
|
97
98
|
function writeSteerFailedLog(pi: PiAPI, hookRetryCount: number, err: unknown): void {
|
|
98
|
-
const message =
|
|
99
|
+
const message = toErrorMessage(err);
|
|
99
100
|
process.stderr.write(
|
|
100
101
|
`[structured-output hook] steer send failed (retry budget NOT consumed, will retry at next turn end): ${message}\n`,
|
|
101
102
|
);
|
|
@@ -110,7 +111,7 @@ function writeSteerFailedLog(pi: PiAPI, hookRetryCount: number, err: unknown): v
|
|
|
110
111
|
} catch (appendErr) {
|
|
111
112
|
// appendEntry 失败不阻断 hook——stderr 通道已落,此处补诊断(同 cache-probe 惯例)
|
|
112
113
|
process.stderr.write(
|
|
113
|
-
`[structured-output hook] appendEntry failed: ${
|
|
114
|
+
`[structured-output hook] appendEntry failed: ${toErrorMessage(appendErr)}\n`,
|
|
114
115
|
);
|
|
115
116
|
}
|
|
116
117
|
}
|