@zhushanwen/pi-retry-rules 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -2
- package/index.ts +1 -0
- package/package.json +61 -3
- package/skills/retry-rules-ext-config/SKILL.md +128 -0
- package/src/config.ts +294 -0
- package/src/index.ts +82 -0
- package/src/rules.ts +179 -0
- package/vitest.config.ts +7 -0
package/README.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zhushanwen/pi-retry-rules
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Retryable error rules for Pi: rewrites finalized `stopReason=error` assistant messages via the
|
|
4
|
+
official `message_end` hook (appending a `[taiji-retry:` marker) so that throttling-class provider
|
|
5
|
+
errors hit pi-ai's native retryable word list and the native retry loop (auto_retry events,
|
|
6
|
+
exponential backoff, failed-attempt omission) takes over.
|
|
7
|
+
|
|
8
|
+
- Decision pipeline: two-tier order — `non_retryable` verdict rules exclude first (per source),
|
|
9
|
+
then same-verdict rules by source priority `custom > remote > builtin`, first match wins.
|
|
10
|
+
- Matching channels: regex against `errorMessage` (ReDoS-guarded) + JSON-snippet dot-path
|
|
11
|
+
extraction (`"${status}: ${body-json}"` openai-compat form).
|
|
12
|
+
- Config file: `<agentDir>/config/retry-rules-ext-config.json` (mtime hot reload; written by the
|
|
13
|
+
taiji runtime — single data source, this extension is a pure consumer).
|
|
14
|
+
- Disable path: no dedicated switch — turn off pi's native retry settings
|
|
15
|
+
(`enabled=false` / `maxRetries=0`); the native gate is checked before the retryable-word-list
|
|
16
|
+
verdict, so rewrites never trigger retries when disabled.
|
|
17
|
+
- Semantic SSOT: `packages/shared/src/retry-rules.ts` (this bundle carries a mirrored copy with
|
|
18
|
+
behavior-lock tests).
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-retry-rules",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Retryable error rules for Pi — message_end rewrite triggers the native retry loop for throttling-class provider errors, with configurable rules (builtin baseline ⊕ remote overlay ⊕ user custom) and ReDoS-guarded matching.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"taiji": {
|
|
8
|
+
"role": "universal",
|
|
9
|
+
"startupConfig": [
|
|
10
|
+
{
|
|
11
|
+
"path": "config/retry-rules-ext-config.json",
|
|
12
|
+
"content": {
|
|
13
|
+
"version": 1,
|
|
14
|
+
"customRules": [],
|
|
15
|
+
"effectiveRules": []
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"pi": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./index.ts"
|
|
23
|
+
],
|
|
24
|
+
"skills": [
|
|
25
|
+
"./skills"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"pi-package",
|
|
30
|
+
"extension",
|
|
31
|
+
"retry",
|
|
32
|
+
"rate-limit"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"files": [
|
|
36
|
+
"src/",
|
|
37
|
+
"!src/__tests__/",
|
|
38
|
+
"index.ts",
|
|
39
|
+
"skills/",
|
|
40
|
+
"vitest.config.ts"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
44
|
+
"@zhushanwen/pi-llm-shared": "0.12.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^24.0.0",
|
|
48
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
49
|
+
"vitest": "^4.1.8",
|
|
50
|
+
"@taiji/shared": "0.10.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@earendil-works/pi-coding-agent": "^1.0.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"@earendil-works/pi-coding-agent": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"typecheck": "npx tsc --noEmit",
|
|
62
|
+
"test": "vitest run"
|
|
63
|
+
}
|
|
6
64
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: retry-rules-ext-config
|
|
3
|
+
description: "配置 @zhushanwen/pi-retry-rules(可重试错误规则)或排查「为什么没重试」时加载。含配置文件路径、RetryRulesExtConfig schema(version/customRules/effectiveRules)、规则字段值域(verdict 三态 retryable/non_retryable/conditional、match 双通道 pattern/jsonPath、kind 值域、source 优先序)、长度与条数上限、判定管线两层序、改写标记 [taiji-retry: 识别、热重载时机、默认值、示例、停用通道(pi 原生 retry 配置)。触发词:重试规则、为什么没重试、没触发重试、429 重试、限流重试、retry-rules 配置、retry rules config。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# retry-rules 配置指南
|
|
7
|
+
|
|
8
|
+
> @zhushanwen/pi-retry-rules:对 `stopReason=error` 的 assistant 消息按重试规则判定,命中 retryable 规则时在 errorMessage 末尾追加 `[taiji-retry:` 标记(追加式,原文完整保留),使错误命中 pi-ai 原生可重试词表,从而触发 pi 原生重试全链路(auto_retry 事件 + 指数退避 + 失败尝试从上下文剔除)。
|
|
9
|
+
|
|
10
|
+
## 配置文件位置
|
|
11
|
+
|
|
12
|
+
`<agentDir>/config/retry-rules-ext-config.json`
|
|
13
|
+
|
|
14
|
+
- `<agentDir>` = pi agent 目录(pi 的 `getAgentDir` 派生,`PI_CODING_AGENT_DIR` 覆盖,默认 `~/.pi/agent`;taiji 隔离环境为 `~/.taiji/agent`)
|
|
15
|
+
- 走 llm-shared 泛型 config(config/ 子目录 + getAgentDir 派生 + mtime+size 缓存 + 原子写)
|
|
16
|
+
- taiji 桌面端由 runtime 启动序列首建该文件(`taiji.startupConfig` 声明);独立 pi 用户手建同样生效
|
|
17
|
+
- 文件缺失 → 空规则集(判定不生效,pi 原生词表兜底),不告警(属正常初始态);坏 JSON / 字段非法 → 回落默认空规则集并记 extension 告警日志(`<agentDir>/logs/retry-rules-<日期>.log`,同一条损坏文件只告警一次,修正保存后自下一轮判定生效)
|
|
18
|
+
|
|
19
|
+
## Schema
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
interface RetryRulesExtConfig {
|
|
23
|
+
version: 1; // 固定为 1
|
|
24
|
+
customRules: RetryableErrorRule[]; // 用户自定义规则段(taiji 设置页编辑写入,source=custom)
|
|
25
|
+
effectiveRules: RetryableErrorRule[]; // 生效规则集(判定管线直读此段)
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `effectiveRules` = taiji runtime 侧合并视图(编译期基线 ⊕ 远程覆盖 ⊕ 用户自定义)的落盘结果;extension 是纯消费方,独立 pi 用户手编该段同样生效
|
|
30
|
+
- 判定只消费 `effectiveRules`;`customRules` 是用户编辑段的持久化形态(runtime 重算合并视图时读取)
|
|
31
|
+
|
|
32
|
+
## 单条规则字段
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
interface RetryableErrorRule {
|
|
36
|
+
id: string; // ≤64 字符、不含空白,如 zhipu-1302;同文件内不可重复
|
|
37
|
+
name: string; // ≤120 字符的简短名称
|
|
38
|
+
verdict: "retryable" | "non_retryable" | "conditional"; // 裁决三态(见下)
|
|
39
|
+
match: Match[]; // 匹配条件列表,1-8 条,全部命中才算命中(AND)
|
|
40
|
+
class: string; // 失败分类(provider/harness/agent/user/engine),必填
|
|
41
|
+
kind?: string; // 失败细分(可选,值域受 class 约束,见下);判定管线不消费
|
|
42
|
+
httpStatusClass?: number; // 400-599 整数(可选,纯溯源/展示分组,判定不消费)
|
|
43
|
+
waitHint?: string; // ≤200 字符等待提示(可选,settings 展示用,判定不消费)
|
|
44
|
+
source: "custom" | "remote" | "builtin"; // 出处(判定排序键,见「判定管线」)
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
match 双通道(二选一按 `kind` 字段区分):
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
{ kind: "pattern"; pattern: string } // 正则通道:对整条 errorMessage 做正则(忽略大小写)
|
|
52
|
+
{ kind: "jsonPath"; path: string; operator: "equals" | "contains" | "exists"; value?: string }
|
|
53
|
+
// jsonPath 通道:从 errorMessage 提取首个 JSON 平衡片段,按点路径取值后比较
|
|
54
|
+
//(openai 兼容层 errorMessage 形如 "429: {json}",body 内业务码可经 path 提取)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
校验规则(违反即整份配置回落空规则集并告警,错误文案含修复动作):
|
|
58
|
+
|
|
59
|
+
| 字段 | 上限/值域 |
|
|
60
|
+
|---|---|
|
|
61
|
+
| id | ≤64 字符,不含空白,文件内唯一 |
|
|
62
|
+
| name | ≤120 字符,非空 |
|
|
63
|
+
| pattern | ≤200 字符;拒绝嵌套量词等高回溯构造(ReDoS 防护);须是合法正则 |
|
|
64
|
+
| jsonPath path | ≤128 字符,不含空白,点路径形态(如 `error.code`、`errors.0.type`) |
|
|
65
|
+
| jsonPath operator | equals / contains / exists 三选一;非 exists 时 value 必填(≤256 字符) |
|
|
66
|
+
| match 条数 | 1-8 条 |
|
|
67
|
+
| 规则条数 | 每段 ≤200 条 |
|
|
68
|
+
| httpStatusClass | 400-599 整数 |
|
|
69
|
+
| waitHint | ≤200 字符 |
|
|
70
|
+
| kind(值域) | provider → `rate_limited` / `protocol_unsupported`;harness → `turn_budget_exhausted` / `timeout`;agent → `no_deliverable`;user → `cancelled`;class=`engine` → 须以 `engine_` 开头(如 `engine_aborted`) |
|
|
71
|
+
|
|
72
|
+
## 判定管线(两层序,命中即断)
|
|
73
|
+
|
|
74
|
+
1. **第一层排除**:任一 `verdict=non_retryable` 规则命中 → 不改写(先排除再放行,账户类错误永不误重试,不分 source)
|
|
75
|
+
2. **第二层裁决**:按 source 优先序 `custom > remote > builtin` 找首条命中规则——`retryable` → 追加标记触发重试;`conditional` → 不改写(「等不到的人/账户动作」类,恢复指引经轮应答传达)
|
|
76
|
+
3. **全未命中** → 不改写,pi 原生词表兜底(`rate.?limit` / `429` / `too many requests` 等仍可触发原生重试)
|
|
77
|
+
|
|
78
|
+
## 生效时机(判定粒度热重载)
|
|
79
|
+
|
|
80
|
+
每轮 message_end 判定直读配置(mtime+size 读时刷新),**文件变更自下一轮判定起生效,无需重启**;判定进行中的当轮不受影响。去重:同一条损坏文件版本只告警一次,修正保存(mtime/size 变化)后重读重校。
|
|
81
|
+
|
|
82
|
+
## 排查「为什么没重试」
|
|
83
|
+
|
|
84
|
+
| 现象 | 处置 |
|
|
85
|
+
|---|---|
|
|
86
|
+
| 错误消息带 `[taiji-retry:` 标记但没重试 | 检查 pi 原生「LLM 调用重试」设置(`enabled=false` 或重试次数已耗尽)——原生重试门在词表判定之前检查,标记只是让错误可被判为可重试 |
|
|
87
|
+
| 错误消息不带标记且没重试 | 无规则命中且未落 pi 原生词表;确认该错误码事实是否已登记规则(可加 customRules),或错误消息形态与 pattern/jsonPath 通道不匹配 |
|
|
88
|
+
| 配置保存后判定不生效 | 查 `<agentDir>/logs/retry-rules-<日期>.log` 告警——校验失败已回落空规则集,按错误文案修正字段值域后保存 |
|
|
89
|
+
| 想彻底停用本机制 | 见下「停用通道」 |
|
|
90
|
+
|
|
91
|
+
## 停用通道
|
|
92
|
+
|
|
93
|
+
无独立 enabled 开关——停用复用 pi 原生「LLM 调用重试」设置:`enabled=false` 或 `maxRetries=0`。原生重试门位于 errorMessage 词表判定之前,前置门关闭后本扩展的改写不会触发任何重试,自然回落失败如实分类。
|
|
94
|
+
|
|
95
|
+
## 配置示例
|
|
96
|
+
|
|
97
|
+
内置基线一条限流规则 + 用户自定义一条 jsonPath 业务码规则:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"version": 1,
|
|
102
|
+
"customRules": [
|
|
103
|
+
{
|
|
104
|
+
"id": "my-provider-1302",
|
|
105
|
+
"name": "某服务商账户限流业务码",
|
|
106
|
+
"verdict": "retryable",
|
|
107
|
+
"class": "provider",
|
|
108
|
+
"kind": "rate_limited",
|
|
109
|
+
"source": "custom",
|
|
110
|
+
"match": [{ "kind": "jsonPath", "path": "error.code", "operator": "equals", "value": "1302" }]
|
|
111
|
+
}
|
|
112
|
+
],
|
|
113
|
+
"effectiveRules": [
|
|
114
|
+
{
|
|
115
|
+
"id": "openai-429",
|
|
116
|
+
"name": "OpenAI 429 限流",
|
|
117
|
+
"verdict": "retryable",
|
|
118
|
+
"class": "provider",
|
|
119
|
+
"kind": "rate_limited",
|
|
120
|
+
"httpStatusClass": 429,
|
|
121
|
+
"source": "builtin",
|
|
122
|
+
"match": [{ "kind": "pattern", "pattern": "rate limit exceeded" }]
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
默认(startupConfig 首建形态):`{ "version": 1, "customRules": [], "effectiveRules": [] }`——空规则集 = 判定不生效,pi 原生词表兜底。
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retry-rules ext-config 读取与校验(bundle 自包含复制体)。
|
|
3
|
+
*
|
|
4
|
+
* 类型与校验语义 SSOT = packages/shared/src/retry-rules.ts(subagent-observability
|
|
5
|
+
* §3.3.12)——extension bundle 自包含不 import shared 运行时代码(network-proxy 同款
|
|
6
|
+
* 惯例:结构类型本地声明 + 行为镜像锁定测试),本文件是复制体;漂移时以 shared 为准,
|
|
7
|
+
* __tests__/config.test.ts 锁两侧行为一致 + 域常量值级对账(import shared
|
|
8
|
+
* RETRY_RULES_DOMAIN,仅测试面消费、不进 bundle——复制体常量漂移即红);
|
|
9
|
+
* 校验通道跨包对拍 = __tests__/retry-rules-validation-parity.test.ts(同一规则输入
|
|
10
|
+
* 矩阵断言两侧 validateRetryRuleFields ok 与错误同判 + 基线全量过本文件读侧 guard;
|
|
11
|
+
* source 校验边界是该测试内登记的有意差异面——shared 组装前不校验 source,本文件
|
|
12
|
+
* 读侧校验组装后落盘形态,source 必填)。
|
|
13
|
+
*
|
|
14
|
+
* 配置文件 = <agentDir>/config/retry-rules-ext-config.json(llm-shared getConfigPath
|
|
15
|
+
* 推导;写入侧 = taiji runtime RMW——内置基线 ⊕ 远程覆盖 ⊕ 用户自定义的合并权威在
|
|
16
|
+
* runtime,extension 是纯消费方,单一数据源)。热重载 = loadConfig 读时刷新(mtime+size),
|
|
17
|
+
* 判定点每次直接调 loadRetryRulesConfig,禁止上层闭包缓存(llm-shared 头注热重载契约;
|
|
18
|
+
* 改动下一轮判定即生效,在途退避不中断——重试状态机由 pi 持有)。
|
|
19
|
+
*
|
|
20
|
+
* 停用通道(控制旋钮,§3.3.12 用户停用通道登记):本配置无独立 enabled 开关——停用
|
|
21
|
+
* 复用既有「LLM 调用重试」卡的 pi 原生 retry 配置(enabled=false / maxRetries=0)。
|
|
22
|
+
* pi 重试决策点先检查 `!settings.enabled || _retryAttempt >= settings.maxRetries` 即直接
|
|
23
|
+
* 不重试(node_modules pi-coding-agent dist/core/agent-session.js:787 实装核实——该门
|
|
24
|
+
* 位于读 errorMessage 词表的 _isRetryableError 之前),本扩展的 message_end 改写只影响
|
|
25
|
+
* 后续可重试性判定,前置门关闭后改写不触发任何重试——停用即自然回落失败如实分类。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { loadConfig } from "@zhushanwen/pi-llm-shared";
|
|
29
|
+
|
|
30
|
+
import type { RetryableErrorRule, RetryRuleMatch, RetryRuleSource, RetryRuleVerdict } from "./rules.js";
|
|
31
|
+
|
|
32
|
+
/** llm-shared loadConfig 的包名(决定文件名 retry-rules-ext-config.json)。 */
|
|
33
|
+
const CONFIG_PKG = "retry-rules";
|
|
34
|
+
|
|
35
|
+
const VERDICTS: readonly string[] = ["retryable", "non_retryable", "conditional"];
|
|
36
|
+
const OPERATORS: readonly string[] = ["equals", "contains", "exists"];
|
|
37
|
+
const SOURCES: readonly string[] = ["custom", "remote", "builtin"];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* per-class kind 合法值域(镜像 shared RETRY_RULE_KINDS_BY_CLASS;权威 =
|
|
41
|
+
* sdk FAILURE_CAUSE_KINDS_BY_CLASS / FailureCause 判别联合)。engine 透传族是
|
|
42
|
+
* `engine_${string}` 开放前缀面不进本表,校验走前缀形态判定。值级对账由
|
|
43
|
+
* __tests__/config.test.ts 锁定(复制体漂移即红)。
|
|
44
|
+
*/
|
|
45
|
+
export const KINDS_BY_CLASS: Readonly<Record<string, readonly string[]>> = {
|
|
46
|
+
provider: ["rate_limited", "protocol_unsupported"],
|
|
47
|
+
harness: ["turn_budget_exhausted", "timeout"],
|
|
48
|
+
agent: ["no_deliverable"],
|
|
49
|
+
user: ["cancelled"],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// 常量导出面仅供镜像锁定测试做值级对账(__tests__/config.test.ts),bundle 体积不受影响
|
|
53
|
+
export const ID_MAX_LEN = 64;
|
|
54
|
+
export const NAME_MAX_LEN = 120;
|
|
55
|
+
export const PATTERN_MAX_LEN = 200;
|
|
56
|
+
export const JSON_PATH_MAX_LEN = 128;
|
|
57
|
+
export const MATCH_VALUE_MAX_LEN = 256;
|
|
58
|
+
export const WAIT_HINT_MAX_LEN = 200;
|
|
59
|
+
export const MATCH_COUNT_MAX = 8;
|
|
60
|
+
export const RULES_MAX = 200;
|
|
61
|
+
/** HTTP 状态大类合法区间(4xx/5xx 语义码段;镜像 shared RetryRuleHttpStatusClass 校验域)。 */
|
|
62
|
+
export const HTTP_STATUS_CLASS_MIN = 400;
|
|
63
|
+
export const HTTP_STATUS_CLASS_MAX = 599;
|
|
64
|
+
|
|
65
|
+
/** 校验结果信封:ok 或面向用户的错误清单(每条含修复动作,逐项收集不短路)。 */
|
|
66
|
+
export type RetryRulesValidationResult = { ok: true } | { ok: false; errors: string[] };
|
|
67
|
+
|
|
68
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
69
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function strWithin(v: unknown, max: number): v is string {
|
|
73
|
+
return typeof v === "string" && v.length > 0 && v.length <= max;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 危险正则静态检测(镜像 shared isDangerousRetryPattern:长度上限 + 嵌套量词 + 相邻区间量词)。 */
|
|
77
|
+
export function isDangerousRetryPattern(pattern: string): boolean {
|
|
78
|
+
if (pattern.length > PATTERN_MAX_LEN) return true;
|
|
79
|
+
if (/\((?:[^()\\]|\\.)*[+*]\)[+*{]/.test(pattern)) return true;
|
|
80
|
+
if (/\{(?:\d+),(?:\d+)?\}\s*\{/.test(pattern)) return true;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 匹配条件校验(pattern / jsonPath 双通道逐项收集)。 */
|
|
85
|
+
function collectMatchErrors(match: unknown, label: string): string[] {
|
|
86
|
+
if (!Array.isArray(match)) {
|
|
87
|
+
return [`${label} 必须是匹配条件列表,请至少添加一条匹配条件`];
|
|
88
|
+
}
|
|
89
|
+
if (match.length === 0) {
|
|
90
|
+
return [`${label} 不能为空,请至少添加一条匹配条件`];
|
|
91
|
+
}
|
|
92
|
+
if (match.length > MATCH_COUNT_MAX) {
|
|
93
|
+
return [`${label} 条数超过上限 ${MATCH_COUNT_MAX},请合并条件`];
|
|
94
|
+
}
|
|
95
|
+
const errors: string[] = [];
|
|
96
|
+
for (let i = 0; i < match.length; i++) {
|
|
97
|
+
const item = match[i];
|
|
98
|
+
const at = `${label} 第 ${i + 1} 条`;
|
|
99
|
+
if (!isRecord(item)) {
|
|
100
|
+
errors.push(`${at} 必须是对象,请重新编辑该匹配条件`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
errors.push(...collectOneMatchErrors(item, at));
|
|
104
|
+
}
|
|
105
|
+
return errors;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 单条匹配条件校验(按 kind 双通道分发;未知通道报错)。 */
|
|
109
|
+
function collectOneMatchErrors(item: Record<string, unknown>, at: string): string[] {
|
|
110
|
+
if (item["kind"] === "pattern") return collectPatternMatchErrors(item, at);
|
|
111
|
+
if (item["kind"] === "jsonPath") return collectJsonPathMatchErrors(item, at);
|
|
112
|
+
return [`${at} 匹配通道必须是 pattern 或 jsonPath,请重新选择匹配方式`];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** pattern 通道校验(长度上限 → 危险构造 → 语法有效性,逐项收集)。 */
|
|
116
|
+
function collectPatternMatchErrors(item: Record<string, unknown>, at: string): string[] {
|
|
117
|
+
const errors: string[] = [];
|
|
118
|
+
const pattern = item["pattern"];
|
|
119
|
+
if (!strWithin(pattern, PATTERN_MAX_LEN)) {
|
|
120
|
+
errors.push(`${at}(正则)为空或超过 ${PATTERN_MAX_LEN} 字符,请缩短正则表达式`);
|
|
121
|
+
return errors;
|
|
122
|
+
}
|
|
123
|
+
if (isDangerousRetryPattern(pattern)) {
|
|
124
|
+
errors.push(`${at}(正则)包含嵌套量词等高回溯构造,存在卡死风险,请简化正则(如用具体字符类替换 .+ 嵌套)`);
|
|
125
|
+
return errors;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
new RegExp(pattern as string, "i");
|
|
129
|
+
} catch {
|
|
130
|
+
errors.push(`${at}(正则)语法无效,请修正后保存`);
|
|
131
|
+
}
|
|
132
|
+
return errors;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** jsonPath 通道校验(点路径形态 → 比较方式词表 → 比较值非 exists 必填)。 */
|
|
136
|
+
function collectJsonPathMatchErrors(item: Record<string, unknown>, at: string): string[] {
|
|
137
|
+
const errors: string[] = [];
|
|
138
|
+
const path = item["path"];
|
|
139
|
+
const operator = item["operator"];
|
|
140
|
+
const value = item["value"];
|
|
141
|
+
if (!strWithin(path, JSON_PATH_MAX_LEN) || /\s/.test(path)) {
|
|
142
|
+
return [`${at}(JSON 路径)为空、超过 ${JSON_PATH_MAX_LEN} 字符或含空白,请填写如 error.code 的点路径`];
|
|
143
|
+
}
|
|
144
|
+
if (typeof operator !== "string" || !OPERATORS.includes(operator)) {
|
|
145
|
+
return [`${at}(JSON 路径)比较方式必须是 equals / contains / exists 之一`];
|
|
146
|
+
}
|
|
147
|
+
if (operator !== "exists" && !strWithin(value, MATCH_VALUE_MAX_LEN)) {
|
|
148
|
+
errors.push(`${at}(JSON 路径)比较值不能为空且不超过 ${MATCH_VALUE_MAX_LEN} 字符`);
|
|
149
|
+
}
|
|
150
|
+
return errors;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* kind 字段校验(镜像 shared collectKindErrors:值域闭合面按 class 归属判定):
|
|
155
|
+
* class ∈ KINDS_BY_CLASS → kind ∈ 该 class 词表;class="engine" → engine_ 前缀开放面;
|
|
156
|
+
* class 缺失/非法时不单独判 kind 值域(class 自身错误已收集)。
|
|
157
|
+
*/
|
|
158
|
+
function collectKindErrors(kind: unknown, failureClass: unknown): string[] {
|
|
159
|
+
if (kind === undefined) return [];
|
|
160
|
+
if (typeof kind !== "string" || kind === "") {
|
|
161
|
+
return [`失败分类细分(kind)必须是非空字符串: ${JSON.stringify(kind) ?? String(kind)}`];
|
|
162
|
+
}
|
|
163
|
+
if (failureClass === "engine") {
|
|
164
|
+
if (!kind.startsWith("engine_")) {
|
|
165
|
+
return [`engine 类规则的 kind 必须以 engine_ 开头(如 engine_aborted): ${kind}`];
|
|
166
|
+
}
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
const allowed = typeof failureClass === "string" ? KINDS_BY_CLASS[failureClass] : undefined;
|
|
170
|
+
if (allowed === undefined) return [];
|
|
171
|
+
if (!allowed.includes(kind)) {
|
|
172
|
+
return [
|
|
173
|
+
`kind 必须是该分类的合法值之一(${allowed.join(" / ")}): ${kind}——请修正 kind 或改选匹配的分类(class)`,
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 单条规则校验(镜像 shared validateRetryRuleFields;source 字段同时域校验)。 */
|
|
180
|
+
export function validateRetryRuleFields(rule: unknown): RetryRulesValidationResult {
|
|
181
|
+
if (!isRecord(rule)) {
|
|
182
|
+
return { ok: false, errors: [`重试规则必须是对象: ${JSON.stringify(rule) ?? String(rule)}`] };
|
|
183
|
+
}
|
|
184
|
+
const errors = [
|
|
185
|
+
...collectRuleHeaderErrors(rule),
|
|
186
|
+
...collectMatchErrors(rule["match"], "匹配条件"),
|
|
187
|
+
...collectRuleOptionalFieldErrors(rule),
|
|
188
|
+
];
|
|
189
|
+
return errors.length > 0 ? { ok: false, errors } : { ok: true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 头字段校验(id / name / verdict / class / kind / source,逐项收集不短路)。 */
|
|
193
|
+
function collectRuleHeaderErrors(rule: Record<string, unknown>): string[] {
|
|
194
|
+
const errors: string[] = [];
|
|
195
|
+
const { id, name, verdict, class: failureClass, source } = rule;
|
|
196
|
+
if (!strWithin(id, ID_MAX_LEN) || /\s/.test(id)) {
|
|
197
|
+
errors.push(`规则 ID 为空、超过 ${ID_MAX_LEN} 字符或含空白,请填写如 zhipu-1302 的短标识`);
|
|
198
|
+
}
|
|
199
|
+
if (!strWithin(name, NAME_MAX_LEN)) {
|
|
200
|
+
errors.push(`规则名称为空或超过 ${NAME_MAX_LEN} 字符,请填写简短名称`);
|
|
201
|
+
}
|
|
202
|
+
if (typeof verdict !== "string" || !VERDICTS.includes(verdict)) {
|
|
203
|
+
errors.push(`裁决必须是 retryable / non_retryable / conditional 之一: ${JSON.stringify(verdict) ?? String(verdict)}`);
|
|
204
|
+
}
|
|
205
|
+
if (typeof failureClass !== "string") {
|
|
206
|
+
errors.push("失败分类(class)不能为空,请选择该错误所属的分类");
|
|
207
|
+
}
|
|
208
|
+
errors.push(...collectKindErrors(rule["kind"], failureClass));
|
|
209
|
+
if (typeof source !== "string" || !SOURCES.includes(source)) {
|
|
210
|
+
errors.push(`规则出处(source)必须是 custom / remote / builtin 之一: ${JSON.stringify(source) ?? String(source)}`);
|
|
211
|
+
}
|
|
212
|
+
return errors;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 可选字段校验(httpStatusClass / waitHint,提供才校验)。 */
|
|
216
|
+
function collectRuleOptionalFieldErrors(rule: Record<string, unknown>): string[] {
|
|
217
|
+
const errors: string[] = [];
|
|
218
|
+
if (rule["httpStatusClass"] !== undefined) {
|
|
219
|
+
const c = rule["httpStatusClass"];
|
|
220
|
+
if (typeof c !== "number" || !Number.isInteger(c) || c < HTTP_STATUS_CLASS_MIN || c > HTTP_STATUS_CLASS_MAX) {
|
|
221
|
+
errors.push(`HTTP 状态大类必须是 ${HTTP_STATUS_CLASS_MIN}-${HTTP_STATUS_CLASS_MAX} 的整数: ${JSON.stringify(c) ?? String(c)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (rule["waitHint"] !== undefined && !strWithin(rule["waitHint"], WAIT_HINT_MAX_LEN)) {
|
|
225
|
+
errors.push(`等待提示为空或超过 ${WAIT_HINT_MAX_LEN} 字符,请留空或填写简短提示`);
|
|
226
|
+
}
|
|
227
|
+
return errors;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function validateRuleList(rules: unknown, label: string): string[] {
|
|
231
|
+
if (!Array.isArray(rules)) return [`${label} 必须是规则列表`];
|
|
232
|
+
if (rules.length > RULES_MAX) return [`${label} 条数超过上限 ${RULES_MAX}`];
|
|
233
|
+
const errors: string[] = [];
|
|
234
|
+
const seen = new Set<string>();
|
|
235
|
+
for (let i = 0; i < rules.length; i++) {
|
|
236
|
+
const result = validateRetryRuleFields(rules[i]);
|
|
237
|
+
if (!result.ok) errors.push(...result.errors.map((e) => `${label} 第 ${i + 1} 条:${e}`));
|
|
238
|
+
const id = isRecord(rules[i]) ? rules[i]["id"] : undefined;
|
|
239
|
+
if (typeof id === "string" && id !== "") {
|
|
240
|
+
if (seen.has(id)) errors.push(`${label} 第 ${i + 1} 条:规则 ID「${id}」重复,请修改 ID`);
|
|
241
|
+
seen.add(id);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return errors;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 默认配置:空规则集(判定不生效 = pi 原生词表兜底;与 package.json startupConfig 声明深相等)。 */
|
|
248
|
+
export const DEFAULT_RETRY_RULES_EXT_CONFIG = {
|
|
249
|
+
version: 1 as const,
|
|
250
|
+
customRules: [],
|
|
251
|
+
effectiveRules: [],
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** ext-config 文件形状 guard(读侧 normalize 前置)。 */
|
|
255
|
+
export function isRetryRulesExtConfig(v: unknown): v is {
|
|
256
|
+
version: 1;
|
|
257
|
+
customRules: RetryableErrorRule[];
|
|
258
|
+
effectiveRules: RetryableErrorRule[];
|
|
259
|
+
} {
|
|
260
|
+
if (!isRecord(v) || v["version"] !== 1) return false;
|
|
261
|
+
if (!Array.isArray(v["customRules"]) || !Array.isArray(v["effectiveRules"])) return false;
|
|
262
|
+
return validateRuleList(v["customRules"], "customRules").length === 0 && validateRuleList(v["effectiveRules"], "effectiveRules").length === 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* loadConfig 的 normalize:校验通过的 raw 即合法配置(校验 = 运行时 guard,断言有兜底)。
|
|
267
|
+
* 非法配置 throw(含逐项错误清单)→ loadConfig 回落 defaults 并把消息交给 onWarning——
|
|
268
|
+
* 非法配置不静默修正(network-proxy 域同款语义),回落空集 = 判定不生效的安全态。
|
|
269
|
+
*/
|
|
270
|
+
export function normalizeRetryRulesConfig(raw: unknown): {
|
|
271
|
+
version: 1;
|
|
272
|
+
customRules: RetryableErrorRule[];
|
|
273
|
+
effectiveRules: RetryableErrorRule[];
|
|
274
|
+
} {
|
|
275
|
+
if (!isRetryRulesExtConfig(raw)) {
|
|
276
|
+
const errors = [
|
|
277
|
+
...validateRuleList(isRecord(raw) ? raw["customRules"] : undefined, "customRules"),
|
|
278
|
+
...validateRuleList(isRecord(raw) ? raw["effectiveRules"] : undefined, "effectiveRules"),
|
|
279
|
+
];
|
|
280
|
+
throw new Error((errors.length > 0 ? errors : ["配置形状必须是 { version: 1, customRules, effectiveRules }"]).join(";"));
|
|
281
|
+
}
|
|
282
|
+
return raw;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 读取配置(llm-shared mtime+size 读时刷新热重载;调用方每次判定直取,禁闭包缓存)。 */
|
|
286
|
+
export function loadRetryRulesConfig(onWarning?: (msg: string) => void): {
|
|
287
|
+
version: 1;
|
|
288
|
+
customRules: RetryableErrorRule[];
|
|
289
|
+
effectiveRules: RetryableErrorRule[];
|
|
290
|
+
} {
|
|
291
|
+
return loadConfig(CONFIG_PKG, DEFAULT_RETRY_RULES_EXT_CONFIG, normalizeRetryRulesConfig, onWarning);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export type { RetryableErrorRule, RetryRuleMatch, RetryRuleSource, RetryRuleVerdict };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retryable Error Rules Extension(subagent-observability §3.3.12 / W6,探针实证路径一)。
|
|
3
|
+
*
|
|
4
|
+
* 机制:pi 无「重试决策」extension 钩子(auto_retry 是 session 内部事件,不进 extension
|
|
5
|
+
* 事件族),但 `message_end` 官方钩子可原地替换已定稿消息(MessageEndEventResult.message,
|
|
6
|
+
* types.d.ts:1078-1081「replacement must keep the original message role」)。本扩展对
|
|
7
|
+
* stopReason=error 的 assistant 消息按重试规则判定,命中 retryable 规则时在 errorMessage
|
|
8
|
+
* 末尾追加 `[taiji-retry:` 锚标记(追加式非替换式——原文完整保留,pi 会话记录持久化形态
|
|
9
|
+
* 无信息丢失),使 pi-ai 的 isRetryableAssistantError 原生词表命中(`rate.?limit`/`429`/
|
|
10
|
+
* `too many requests`),从而触发 pi 原生重试全链路(auto_retry 事件族 + 指数退避 + 失败
|
|
11
|
+
* 尝试从模型上下文剔除,探针双组对照实证:改写组全链路触发、对照组直接终局)。
|
|
12
|
+
*
|
|
13
|
+
* 判定管线两层序与双匹配通道见 rules.ts(SSOT = packages/shared/src/retry-rules.ts);
|
|
14
|
+
* 配置热重载见 config.ts(mtime+size 读时刷新,下一轮判定即生效)。
|
|
15
|
+
*
|
|
16
|
+
* 停用通道:无独立开关——停用复用 pi 原生 retry 配置(enabled=false / maxRetries=0),
|
|
17
|
+
* pi 重试决策点在 errorMessage 词表判定之前先检查该门(agent-session.js:787 实装核实),
|
|
18
|
+
* 停用后改写不触发任何重试,自然回落失败如实分类(档 A)。
|
|
19
|
+
*
|
|
20
|
+
* 接线:mandatory-extensions.json 登 infrastructure(structured-output 同链)——infrastructure
|
|
21
|
+
* 包恒载进主进程与孙进程扩展白名单(pi-host resolveGrandchildExtensionPaths argv 通道),
|
|
22
|
+
* 零新增接线机制。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
|
|
27
|
+
import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
|
|
28
|
+
|
|
29
|
+
import { loadRetryRulesConfig } from "./config.js";
|
|
30
|
+
import { appendRetryMarker, evaluateRetryRules } from "./rules.js";
|
|
31
|
+
|
|
32
|
+
type PiAPI = ExtensionAPI;
|
|
33
|
+
|
|
34
|
+
const logger = getLogger("retry-rules");
|
|
35
|
+
|
|
36
|
+
/** message_end 事件中本扩展消费的消息字段面(AgentMessage 判定所需的窄视图)。 */
|
|
37
|
+
interface AssistantErrorMessage { // oe-exempt:20261024:test:message_end 改写入参的结构化窄面(pi 消息子集),单消费点 rewriteMessageForRetry;真实第二变体 = pi 消息形态演化,暂内联
|
|
38
|
+
role: string;
|
|
39
|
+
stopReason?: string;
|
|
40
|
+
errorMessage?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// re-export 供测试与外部直接调用(import 路径 ../src/index.js 保持稳定)
|
|
44
|
+
export { appendRetryMarker, evaluateRetryRules } from "./rules.js";
|
|
45
|
+
export {
|
|
46
|
+
DEFAULT_RETRY_RULES_EXT_CONFIG,
|
|
47
|
+
isDangerousRetryPattern,
|
|
48
|
+
loadRetryRulesConfig,
|
|
49
|
+
validateRetryRuleFields,
|
|
50
|
+
} from "./config.js";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 对单条已定稿消息执行重试判定;命中 retryable 规则返回改写消息(追加标记),否则 undefined。
|
|
54
|
+
* 泛型守恒:入参 T 透传到返回 message(spread + errorMessage 覆盖,其余字段原样)——
|
|
55
|
+
* 钩子处传入 pi 的 AgentMessage 即返回 AgentMessage 形态(MessageEndEventResult 契约:
|
|
56
|
+
* replacement 须保持原消息 role),独立导出(纯函数形态)供单测覆盖。
|
|
57
|
+
*/
|
|
58
|
+
export function rewriteMessageForRetry<T extends AssistantErrorMessage>(message: T): { message: T } | undefined {
|
|
59
|
+
if (message.role !== "assistant" || message.stopReason !== "error") return undefined;
|
|
60
|
+
if (typeof message.errorMessage !== "string" || message.errorMessage === "") return undefined;
|
|
61
|
+
// 非法配置 / 坏 JSON:loadConfig 已回落 defaults(空规则集,判定不生效——pi 原生词表兜底),
|
|
62
|
+
// 告警接 extension logger(appendEntry 持久化 + <agentDir>/logs/retry-rules-<日期>.log 文件日志),
|
|
63
|
+
// 不传回调则降级零痕迹。去重 = loadConfig 的 mtime+size 缓存(llm-shared config.ts:损坏文件
|
|
64
|
+
// 按 mtime+size 缓存 defaults,不重读不重复回调)——每条 message_end 判定虽直读本入口,
|
|
65
|
+
// 同一损坏文件版本只告警一次;用户修正保存(mtime/size 变化)后重读重校再告警。
|
|
66
|
+
const config = loadRetryRulesConfig((warning) => logger.warn(`${warning}(已回落空规则集,本扩展重试判定暂不生效;修正配置文件后自下一轮判定生效)`));
|
|
67
|
+
const decision = evaluateRetryRules(message.errorMessage, config.effectiveRules);
|
|
68
|
+
if (decision.action !== "rewrite") return undefined;
|
|
69
|
+
return { message: { ...message, errorMessage: appendRetryMarker(message.errorMessage) } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Extension entry ────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export default function retryRulesExtension(pi: PiAPI): void {
|
|
75
|
+
// 日志通道注入(extension-logger 两阶段初始化:工厂拿 pi → setPiHandle,最早期调用);
|
|
76
|
+
// 注入后 module 级 logger 的 warn 经 appendEntry 落 session JSONL(不进 LLM 上下文)。
|
|
77
|
+
setPiHandle(pi);
|
|
78
|
+
|
|
79
|
+
pi.on("message_end", (event) => {
|
|
80
|
+
return rewriteMessageForRetry(event.message);
|
|
81
|
+
});
|
|
82
|
+
}
|
package/src/rules.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 重试规则判定管线(bundle 自包含复制体)。
|
|
3
|
+
*
|
|
4
|
+
* 语义 SSOT = packages/shared/src/retry-rules.ts(subagent-observability §3.3.12)——
|
|
5
|
+
* extension bundle 自包含不 import shared 运行时代码(extensions 对 shared 的既有惯例:
|
|
6
|
+
* 结构类型本地声明 + 行为镜像锁定测试),本文件是复制体;漂移时以 shared 为准,
|
|
7
|
+
* __tests__/rules.test.ts 锁两侧行为一致(用例与 shared 同款对拍)。
|
|
8
|
+
*
|
|
9
|
+
* 判定管线两层序(设计定稿):
|
|
10
|
+
* 第一层按 verdict 排除——non_retryable 规则优先于 retryable/conditional(先排除再放行,
|
|
11
|
+
* 账户类错误永不误重试,不区分 source);第二层同 verdict 内按 source 序
|
|
12
|
+
* custom > remote > builtin,命中即断;全未命中 = 不改写(pi 原生词表兜底)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ───────────────────────── 词表 ─────────────────────────
|
|
16
|
+
|
|
17
|
+
/** 规则裁决三态(verdict 字面统一下划线形态)。 */
|
|
18
|
+
export type RetryRuleVerdict = "retryable" | "non_retryable" | "conditional";
|
|
19
|
+
|
|
20
|
+
/** 规则出处(两层序第二层排序键:custom > remote > builtin)。 */
|
|
21
|
+
export type RetryRuleSource = "custom" | "remote" | "builtin";
|
|
22
|
+
|
|
23
|
+
/** 单条匹配条件(对 errorMessage 文本;match[] 全部命中才算命中,AND 语义)。 */
|
|
24
|
+
export type RetryRuleMatch =
|
|
25
|
+
| { kind: "pattern"; pattern: string }
|
|
26
|
+
| { kind: "jsonPath"; path: string; operator: "equals" | "contains" | "exists"; value?: string };
|
|
27
|
+
|
|
28
|
+
/** 单条重试判定规则(class/kind 字段为分类码表共用 schema 位——判定管线不消费,§3.3.12 条目 schema)。 */
|
|
29
|
+
export interface RetryableErrorRule { // oe-exempt:20261026:framework:ext-config 契约的 bundle 自包含复制体声明(SSOT=shared,镜像锁定测试锚定)
|
|
30
|
+
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
verdict: RetryRuleVerdict;
|
|
34
|
+
match: RetryRuleMatch[];
|
|
35
|
+
class: string;
|
|
36
|
+
/** FailureCause kind 细分(可选,值域校验镜像 shared validateRetryRuleFields——判定管线不消费)。 */
|
|
37
|
+
kind?: string;
|
|
38
|
+
httpStatusClass?: number;
|
|
39
|
+
waitHint?: string;
|
|
40
|
+
source: RetryRuleSource;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** ext-config 判定消费段(effectiveRules)所在文件 schema。 */
|
|
44
|
+
export interface RetryRulesExtConfig { // oe-exempt:20261026:framework:ext-config 契约的 bundle 自包含复制体声明(SSOT=shared,镜像锁定测试锚定)
|
|
45
|
+
|
|
46
|
+
version: 1;
|
|
47
|
+
customRules: RetryableErrorRule[];
|
|
48
|
+
effectiveRules: RetryableErrorRule[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 域常量(镜像 shared RETRY_RULES_DOMAIN.textMaxLen;导出仅供镜像锁定测试值级对账)。 */
|
|
52
|
+
export const TEXT_MAX_LEN = 8192;
|
|
53
|
+
|
|
54
|
+
/** SOURCE 判定序(两层序第二层):custom > remote > builtin。 */
|
|
55
|
+
const SOURCE_ORDER: readonly RetryRuleSource[] = ["custom", "remote", "builtin"];
|
|
56
|
+
|
|
57
|
+
// ───────────────────────── 匹配(双通道 + ReDoS 运行时防护) ─────────────────────────
|
|
58
|
+
|
|
59
|
+
/** JSON 片段提取:首个 `{` 起的括号平衡段(字符串字面量感知);无平衡段返回 null。 */
|
|
60
|
+
export function extractJsonSegment(text: string): string | null {
|
|
61
|
+
const start = text.indexOf("{");
|
|
62
|
+
if (start < 0) return null;
|
|
63
|
+
let depth = 0;
|
|
64
|
+
let inString = false;
|
|
65
|
+
let escaped = false;
|
|
66
|
+
for (let i = start; i < text.length; i++) {
|
|
67
|
+
const ch = text[i];
|
|
68
|
+
if (escaped) {
|
|
69
|
+
escaped = false;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (ch === "\\") {
|
|
73
|
+
escaped = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (ch === '"') {
|
|
77
|
+
inString = !inString;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (inString) continue;
|
|
81
|
+
if (ch === "{") depth++;
|
|
82
|
+
else if (ch === "}") {
|
|
83
|
+
depth--;
|
|
84
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 点路径取值(`error.code` / `errors.0.type`);任一层缺失返回 undefined。 */
|
|
91
|
+
function readJsonPath(root: unknown, path: string): unknown {
|
|
92
|
+
let current: unknown = root;
|
|
93
|
+
for (const segment of path.split(".")) {
|
|
94
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
95
|
+
current = (current as Record<string, unknown>)[segment];
|
|
96
|
+
}
|
|
97
|
+
return current;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 单条匹配条件判定(message 已截断)。 */
|
|
101
|
+
function matchesCondition(message: string, condition: RetryRuleMatch): boolean {
|
|
102
|
+
if (condition.kind === "pattern") {
|
|
103
|
+
try {
|
|
104
|
+
return new RegExp(condition.pattern, "i").test(message);
|
|
105
|
+
} catch {
|
|
106
|
+
// 运行时兜底(写侧校验已拒非法源;本侧防线覆盖绕过 runtime 直改文件的第三方便携形态):无效源恒不命中
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const segment = extractJsonSegment(message);
|
|
111
|
+
if (segment === null) return false;
|
|
112
|
+
let parsed: unknown;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(segment);
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const actual = readJsonPath(parsed, condition.path);
|
|
119
|
+
if (condition.operator === "exists") return actual !== undefined;
|
|
120
|
+
if (actual === undefined || actual === null) return false;
|
|
121
|
+
const actualText = typeof actual === "string" ? actual : JSON.stringify(actual);
|
|
122
|
+
if (condition.operator === "equals") return actualText === condition.value;
|
|
123
|
+
return actualText.includes(condition.value ?? "");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 单条规则判定:match[] 全部命中才算命中(AND)。 */
|
|
127
|
+
export function matchRetryRule(message: string, rule: RetryableErrorRule): boolean {
|
|
128
|
+
return rule.match.every((condition) => matchesCondition(message, condition));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ───────────────────────── 判定管线(两层序,命中即断) ─────────────────────────
|
|
132
|
+
|
|
133
|
+
/** 判定结论:rewrite = 追加标记触发 pi 原生重试;keep = 不改写(reason 说明不动因)。 */
|
|
134
|
+
export type RetryRuleDecision =
|
|
135
|
+
| { action: "rewrite" }
|
|
136
|
+
| { action: "keep"; reason: "empty_message" | "non_retryable_hit" | "conditional_hit" | "no_rule_match" };
|
|
137
|
+
|
|
138
|
+
export function evaluateRetryRules(message: string, rules: readonly RetryableErrorRule[]): RetryRuleDecision {
|
|
139
|
+
if (message.trim() === "") return { action: "keep", reason: "empty_message" };
|
|
140
|
+
const truncated = message.length > TEXT_MAX_LEN ? message.slice(0, TEXT_MAX_LEN) : message;
|
|
141
|
+
// 第一层:verdict 排除(先排除再放行——用户可重试规则击穿账户类硬约束的方向被结构封死)
|
|
142
|
+
for (const rule of rules) {
|
|
143
|
+
if (rule.verdict === "non_retryable" && matchRetryRule(truncated, rule)) {
|
|
144
|
+
return { action: "keep", reason: "non_retryable_hit" };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// 第二层:同 verdict 内按 source 序,首个命中规则裁决(命中即断)
|
|
148
|
+
for (const source of SOURCE_ORDER) {
|
|
149
|
+
for (const rule of rules) {
|
|
150
|
+
if (rule.source !== source) continue;
|
|
151
|
+
if (!matchRetryRule(truncated, rule)) continue;
|
|
152
|
+
if (rule.verdict === "retryable") return { action: "rewrite" };
|
|
153
|
+
return { action: "keep", reason: "conditional_hit" };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { action: "keep", reason: "no_rule_match" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ───────────────────────── 改写标记(探针实证锚文本) ─────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 追加式改写标记(§3.3.12 改写落盘约束 + 探针实证):
|
|
163
|
+
* - 前缀 `[taiji-retry:` 是本机制触发重试的识别约定(现无按标记消费的观测方,观测侧
|
|
164
|
+
* 可凭标记区分机制触发与 pi 原生词表命中的重试——后者无标记);
|
|
165
|
+
* - 锚文本 `rate limit 429 too many requests` 实测命中 pi-ai RETRYABLE 词表的
|
|
166
|
+
* `rate.?limit` / `429` / `too many requests` 三条,且不含 NON_RETRYABLE 词表任何成员
|
|
167
|
+
* (node_modules @earendil-works/pi-ai dist/utils/retry.js 1.0.0 实装核对);
|
|
168
|
+
* - 追加式非替换式:原 errorMessage 完整保留在前段(pi 会话记录持久化改写形态,无信息丢失)。
|
|
169
|
+
*/
|
|
170
|
+
export const RETRY_REWRITE_MARKER = " [taiji-retry: rate limit 429 too many requests]";
|
|
171
|
+
|
|
172
|
+
/** 改写标记前缀(幂等判定用)。 */
|
|
173
|
+
export const RETRY_REWRITE_MARKER_PREFIX = "[taiji-retry:";
|
|
174
|
+
|
|
175
|
+
/** 追加式改写:原文保留 + 标记追加;已带标记时原样返回(幂等)。 */
|
|
176
|
+
export function appendRetryMarker(errorMessage: string): string {
|
|
177
|
+
if (errorMessage.includes(RETRY_REWRITE_MARKER_PREFIX)) return errorMessage;
|
|
178
|
+
return `${errorMessage}${RETRY_REWRITE_MARKER}`;
|
|
179
|
+
}
|