pi-error-retry 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/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # 更新日志
2
+
3
+ ## 0.2.0
4
+
5
+ - 新增 `/error-retry import <文件路径>` 和 `/error-retry export <文件路径>`。
6
+ - 兼容 DSH v1 规则文件、原生配置对象和裸规则数组;导出文件可直接导入 DSH。
7
+ - 导入先校验、确认后替换,并保留配置冲突和会话生命周期保护。
8
+ - 导出原子创建新文件,不覆盖已有目标;支持默认规则导出、1 MiB 大小限制和失败清理。
9
+ - 源码改由 `librola/pi-extensions-shuyuan` 统一管理,npm 包名保持 `pi-error-retry`。
10
+
11
+ ## 0.1.0
12
+
13
+ - 按规则无限重试匹配的模型错误,并在输出长度截断后自动续写。
14
+ - 提供规则编辑、热更新、配置冲突校验和标准停止行为。
package/README.md CHANGED
@@ -1,3 +1,151 @@
1
- # Temporary Holding Version
1
+ # pi-error-retry
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 独立 Pi 扩展,移植 `@local/dsh-error-retry` 的规则重试行为,并增加输出长度截断后的自动续写及规则导入/导出命令。当前在本机 Pi **1.5.2** 上验证。
4
+
5
+ ## 必须区分的两种行为
6
+
7
+ | 情况 | 行为 | 模型上下文 |
8
+ | --- | --- | --- |
9
+ | `stopReason: "error"` 且规则命中 | 无限重试当前模型请求 | 追加 `context_edit`,省略失败回复;不插入“继续”或新的用户输入 |
10
+ | `stopReason: "length"` | 自动继续完成原任务 | 保留截断回复,追加明确续写提示;不从头重试 |
11
+ | 未匹配错误 | 本扩展不接管 | Pi 原有重试/恢复策略不变 |
12
+ | 用户停止、取消或卸载 | 停止 | 不在后台定时唤醒会话 |
13
+
14
+ Pi 中的 `Response was truncated before completion.` 对应第二种情况。续写不增加错误重试计数,也不使用错误退避。再次截断会再次续写,直至正常完成或用户停止。
15
+
16
+ Pi 自带的有限错误重试先行;它耗尽后,本扩展接管匹配错误。扩展重试发起的新请求仍可能经过 Pi 原有有限重试。不会全局关闭内置重试,也不会把所有错误都设为无限重试。
17
+
18
+ ### 为什么重试不会重放工具
19
+
20
+ 例如:原任务 → 工具 A 已执行 → 请求下一步失败。本扩展仅省略这个失败回复,下一次请求仍包含工具 A 的结果,不重新提交原任务,也不重新执行工具 A。
21
+
22
+ 截断时,Pi 本身会将截断回复中的工具调用标记失败,不执行可能残缺的参数。本扩展保留这些结果,要求模型重新生成尚未执行的调用的完整参数。它与自然工具后续共用一次 continuation,不额外叠加第二次请求。
23
+
24
+ ## 安装
25
+
26
+ 从 npm 安装:
27
+
28
+ ```sh
29
+ pi install npm:pi-error-retry
30
+ ```
31
+
32
+ 本扩展现与 `pi-background-bash` 在[统一仓库](../README.md)管理。直接从 GitHub 安装两个扩展:
33
+
34
+ ```sh
35
+ pi install git:github.com/librola/pi-extensions-shuyuan
36
+ ```
37
+
38
+ 在现有 Pi 会话中执行 `/reload`;新会话自动加载。克隆后的本地源码无需编译,也可临时加载:
39
+
40
+ ```sh
41
+ pi -e ./pi-error-retry/index.ts
42
+ ```
43
+
44
+ 仅安装本扩展时,使用 `pi install ~/pi-dev/pi-extensions-shuyuan/pi-error-retry`。不要与原来的独立包重复加载。
45
+
46
+ 安装声明位于 `~/.pi/agent/settings.json` 的 `packages` 数组。本扩展只注册事件和用户命令,没有新增模型可调用的配置工具。
47
+
48
+ 不要同时启用其他错误恢复/长度续写扩展,避免竞争触发。
49
+
50
+ ## 配置和编辑
51
+
52
+ 用户配置文件:
53
+
54
+ ```text
55
+ ~/.pi/agent/error-retry.json
56
+ ```
57
+
58
+ 设置了 `PI_CODING_AGENT_DIR` 时使用该目录。规则只从用户目录读取,不读取项目里的规则覆盖。
59
+
60
+ ```text
61
+ /error-retry 查看规则数量、配置位置和两种恢复行为
62
+ /error-retry edit 用 Pi 原生 JSON 编辑器修改规则
63
+ /error-retry stop 停止当前请求
64
+ /error-retry import <路径> 校验规则文件,确认后替换当前规则
65
+ /error-retry export <路径> 将当前有效规则导出为 DSH v1 JSON 文件
66
+ ```
67
+
68
+ Pi 原生编辑器适用于 TUI 和支持该对话协议的 RPC 客户端;不提供 DSH 的插件详情页。JSON/print 模式请直接编辑文件。
69
+
70
+ - 规则每次由扩展接管错误前重新读取,不需要重新加载或卸载扩展。
71
+ - 保存时校验配置并原子写入;文件原文充当 revision,跨窗口保存使用排他锁。版本冲突不会覆盖他人修改。
72
+ - 非法草稿或保存失败不显示成功,并可以保留草稿继续编辑、主动读取最新配置,或取消。
73
+ - 保存不取消已经排定的等待;已排定的重试可能完成一次,下一次错误判定读取新规则。立即停止请使用标准停止按钮或 `/error-retry stop`。
74
+ - 非法配置、读取失败或正则匹配超时不被当成命中,也不静默回退为宽泛规则。
75
+
76
+ ### 导入与导出
77
+
78
+ ```text
79
+ /error-retry import ~/Downloads/dsh-error-retry-rules.json
80
+ /error-retry export ~/Downloads/pi-error-retry-rules.json
81
+ /error-retry import "./rules backup.json"
82
+ ```
83
+
84
+ - 导入兼容 DSH 的 `{ "version": 1, "rules": [...] }`、本插件的 `{ "rules": [...] }`,也接受裸规则数组;空数组合法,缺少 `rules`、未知版本、额外字段及非法规则会被拒绝。用户配置文件仍只保存 `{ "rules": [...] }`,格式不变。
85
+ - 导入先解析并复用保存校验,再显示来源、规则数量和风险确认。确认后**替换整组规则,不追加**;取消、校验失败或确认期间发生配置冲突都不会覆盖原配置。TUI 和支持确认对话的 RPC 客户端可用;JSON/print 模式不跳过确认,因而拒绝导入。
86
+ - 导出当前已保存的有效规则,不包含编辑器未保存草稿、会话状态或凭据。没有用户配置文件时导出默认规则,不顺便创建用户配置。输出格式为 `{ "version": 1, "rules": [...] }`,可直接导入 DSH。
87
+ - 导出**只创建新文件,不覆盖已有文件或符号链接**,也不能导出到正在使用的配置路径。目标目录需已存在;文件权限为 0600,原子发布完整内容,失败时清理临时文件。导入和导出文件均限制为 1 MiB。
88
+ - 路径相对于当前会话的工作目录,支持绝对路径、`~/`、空格及成对的单/双引号;不展开环境变量或通配符,不执行 shell。路径是 **Pi 进程所在机器**的文件路径;Pi Web 不会把浏览器本机文件自动上传或下载。
89
+ - 命令不调用模型、不写模型上下文;导入不自动 reload、不取消已排定等待,新规则从下一次错误判定生效。reload 或会话切换会取消尚未完成的导入确认。
90
+
91
+ ### 规则格式
92
+
93
+ ```json
94
+ {
95
+ "rules": [
96
+ { "mode": "contains", "pattern": "Quota service is temporarily unavailable." },
97
+ { "mode": "regex", "pattern": "^temporary overload(?:\\s|$)", "flags": "i" }
98
+ ]
99
+ }
100
+ ```
101
+
102
+ - 规则之间为 **OR**,任意一条命中就无限重试。
103
+ - `contains` 区分大小写,不接受 `flags`。
104
+ - `regex` 使用 JavaScript 正则,不写外围斜线;`flags` 只允许不重复的 `i/m/s/u`,不允许有状态的 `g/y`。
105
+ - `pattern` 不能为空或纯空白;有意义的前后空白会保留。
106
+ - 整组匹配在 `node:vm` 中共享 **25ms** 预算,超时不接管。这里只用 VM 限制正则回溯,不把它当成恶意代码安全沙箱。
107
+ - 显式提供 `rules` 完全替换默认规则,不追加。`{"rules":[]}` 仅关闭本扩展的错误重试,**不会关闭长度续写或 Pi 内置重试**。
108
+
109
+ 不提供文件或省略 `rules` 时,默认与 DSH 源码一致,使用两条完整句子:
110
+
111
+ 1. `Requests have exceeded the throughput limit on your Provisioned-Managed deployment. If you continue to exceed your limit, consider increasing the number of provisioned throughput units deployed.`
112
+ 2. `Your request exceeds the maximum size allowed by your deployment's quota.`
113
+
114
+ `examples/error-retry.json` 提供从 DSH 配置迁移的 10 条示例规则;按需复制到用户配置文件使用。安装不会自动用示例覆盖你的配置,扩展默认列表仍保持上述两条。
115
+
116
+ ## 等待、日志和范围
117
+
118
+ - 匹配错误的重试次数和累计等待时间均不设上限。
119
+ - 本地退避约为 1、2、4、8、16、30 秒,带 10% 抖动,最大 30 秒。
120
+ - 有效 `Retry-After-Ms`/`Retry-After` 响应头优先,支持毫秒、秒和 HTTP 日期;服务端长等待不截短,定时器分段避免溢出。
121
+ - 最终结束边界没有运行取消信号。因此边界只准备重试,实际等待在下一次 `agent_start`、发送模型请求之前进行,使用运行信号与扩展生命周期信号。标准停止可以立即取消长等待。
122
+ - 重试等待显示状态栏;`pi-error-retry.recovery` 非消息条目记录 `retry`、`retry-started` 和 `continue`。计数从当前分支日志恢复,按提供方、模型及规范化策略隔离;成功步骤、新用户输入或规则变化重置。
123
+ - 失败回复及其用量仍保留在原始会话历史和统计中,只从后续模型上下文省略,不删除历史。
124
+ - 用户已排队的新输入或其他扩展已选定的 continuation 优先,不追加竞争请求。
125
+ - 作用范围为加载了此扩展的 Pi Agent 主循环,包括同样加载此包的 Pi 子会话;不包装工具内部 HTTP、辅助总结调用或外部 CLI。外部子代理不会因为父会话加载而自动获得此扩展。
126
+ - 加载扩展不会自动唤醒已经结束的旧错误回合,需要用户手动继续。
127
+
128
+ **风险:无限重试可能产生无限请求和费用。** 配额/请求大小问题未必能靠等待修复,过宽正则也可能永远不成功。请保留明确规则,遇到永久错误及时停止并修正输入或部署。
129
+
130
+ ## 验证
131
+
132
+ 统一开发优先在仓库根目录执行 `npm ci --include=dev --ignore-scripts --legacy-peer-deps` 和 `npm run check`,参见[根目录说明](../README.md#开发与验证)。
133
+
134
+ 也可在本扩展目录独立开发;以下安装方式不会自动安装 peer Pi:
135
+
136
+ ```sh
137
+ npm install --ignore-scripts --legacy-peer-deps
138
+ export PI_TEST_PACKAGE=/path/to/pi-coding-agent/package.json
139
+ npm run check
140
+ npm test
141
+ ```
142
+
143
+ `PI_TEST_PACKAGE` 指向已安装 Pi 的包清单。不设置时从项目依赖中寻找宿主 Pi。测试使用真实 SDK、扩展 TypeScript 加载器、会话上下文投影及模拟 provider;不调用真实模型,也不读取用户模型凭据。临时文件在系统临时目录中创建和清理。
144
+
145
+ 覆盖:101 次连续错误、内置重试串联、请求输入不变、截断前文保留、截断后错误重试、已执行工具不重放、截断工具不执行、取消长等待、规则热更新/冲突/校验、正则超时、退避、服务端等待及日志计数隔离;另覆盖 DSH/原生/裸数组导入、确认与取消、并发配置冲突、生命周期失效、默认规则导出、文件大小边界、路径解析、禁止覆盖、原子发布和磁盘写入失败清理,以及真实 SDK 的命令派发与导入后规则生效。
146
+
147
+ 当前 **51 项测试通过**,TypeScript 严格类型检查通过。
148
+
149
+ 另有一个已独立对照复现的宿主限制:SDK 直接对活动会话调用 `session.dispose()` 时,Pi 1.4.0 会先解绑持久化订阅,随后可能报告 `turn_end could not resolve the persisted assistant entry ID`;仅注册空 `turn_end` 处理器、不加载本扩展也能复现。本扩展不会因此继续请求,也不会访问已失效的上下文。该宿主问题未修改;正常标准停止已验证,SDK 集成建议先 `await session.abort()` 再 `session.dispose()`。
150
+
151
+ 真实提供方故障恢复和客户端 UI 交互尚未人工验收;SDK 与加载器验证不等同于实际付费请求验收。
@@ -0,0 +1,45 @@
1
+ {
2
+ "rules": [
3
+ {
4
+ "mode": "contains",
5
+ "pattern": "Requests have exceeded the throughput limit on your Provisioned-Managed deployment. If you continue to exceed your limit, consider increasing the number of provisioned throughput units deployed."
6
+ },
7
+ {
8
+ "mode": "contains",
9
+ "pattern": "Your request exceeds the maximum size allowed by your deployment's quota."
10
+ },
11
+ {
12
+ "mode": "contains",
13
+ "pattern": "No available PLB channels. Is the PLB down"
14
+ },
15
+ {
16
+ "mode": "contains",
17
+ "pattern": "has been reached: 600000 tokens per 60 seconds."
18
+ },
19
+ {
20
+ "mode": "contains",
21
+ "pattern": "The model was unable to complete inference due to an internal error."
22
+ },
23
+ {
24
+ "mode": "regex",
25
+ "pattern": "Models Proxy:\\s*The\\s+(?:[A-Za-z][A-Za-z-]*\\s+){0,4}limit for model\\s+\\\\?\"[^\"\\r\\n]+\\\\?\"\\s+has been reached:\\s*\\d+(?:\\.\\d+)?\\s+tokens per\\s+\\d+(?:\\.\\d+)?\\s+seconds?\\.\\s*Please retry after\\s+\\d+(?:\\.\\d+)?\\s+seconds?\\.",
26
+ "flags": "i"
27
+ },
28
+ {
29
+ "mode": "contains",
30
+ "pattern": "Models Proxy: 502 Bad Gateway"
31
+ },
32
+ {
33
+ "mode": "contains",
34
+ "pattern": "pi-ai stream idle timeout after 300000ms"
35
+ },
36
+ {
37
+ "mode": "contains",
38
+ "pattern": "Quota service is temporarily unavailable."
39
+ },
40
+ {
41
+ "mode": "contains",
42
+ "pattern": "Upstream service unavailable"
43
+ }
44
+ ]
45
+ }
package/index.ts ADDED
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ExtensionAPI, SessionEntry, TurnEndEvent } from "@earendil-works/pi-coding-agent";
5
+ import { readConfig, saveConfig } from "./src/config.ts";
6
+ import { exportRulesFile, readRulesFile, resolveRulesFilePath } from "./src/transfer.ts";
7
+ import { matchesRules, policyKey, providerRetryAfter, retryDelay, waitForRetry } from "./src/rules.ts";
8
+
9
+ export const RECOVERY_ENTRY = "pi-error-retry.recovery";
10
+ export const CONTINUE_MESSAGE =
11
+ "上一条回复因单次输出长度限制被截断。请从停止处继续完成原任务,不要重述已输出内容,不要重复已完成的工具操作。如果工具调用被截断,请重新生成完整参数,仅重发尚未执行的调用。";
12
+
13
+ interface RetryRecord {
14
+ kind: "retry";
15
+ retryId: string;
16
+ retry: number;
17
+ policyKey: string;
18
+ provider: string;
19
+ model: string;
20
+ messageEntryId: string;
21
+ delayMs: number;
22
+ errorMessage: string;
23
+ }
24
+
25
+ export function previousRetry(branch: SessionEntry[], key: string, provider: string, model: string): RetryRecord | undefined {
26
+ // 仅重建当前分支的当前请求步骤;成功、用户新输入或续写消息均开启新步骤。
27
+ for (const entry of [...branch].reverse()) {
28
+ if (entry.type === "message") {
29
+ const message = entry.message;
30
+ if (message.role === "user" || (message.role === "assistant" && message.stopReason !== "error")) return;
31
+ }
32
+ if (entry.type === "custom_message") return;
33
+ if (entry.type !== "custom" || entry.customType !== RECOVERY_ENTRY) continue;
34
+ const record = entry.data as Partial<RetryRecord> | undefined;
35
+ if (record?.kind !== "retry" || record.provider !== provider || record.model !== model) continue;
36
+ if (record.policyKey !== key) return;
37
+ if (typeof record.retry === "number" && Number.isSafeInteger(record.retry) && record.retry > 0 &&
38
+ typeof record.retryId === "string") return record as RetryRecord;
39
+ return;
40
+ }
41
+ }
42
+
43
+ export function registerErrorRetry(pi: ExtensionAPI, configPath: string): void {
44
+ let lifetime = new AbortController();
45
+ let lastTurn: { event: TurnEndEvent; responseDelay: number | undefined } | undefined;
46
+ let responseDelay: number | undefined;
47
+ let pendingRetry: RetryRecord | undefined;
48
+
49
+ pi.on("session_start", () => {
50
+ lifetime = new AbortController();
51
+ lastTurn = undefined;
52
+ pendingRetry = undefined;
53
+ });
54
+ pi.on("session_shutdown", () => lifetime.abort());
55
+ pi.on("before_agent_start", () => {
56
+ // 加载扩展不会唤醒旧错误;新的用户请求也不能继承旧的排队等待。
57
+ lastTurn = undefined;
58
+ pendingRetry = undefined;
59
+ });
60
+ pi.on("turn_start", () => { responseDelay = undefined; });
61
+ pi.on("after_provider_response", (event) => { responseDelay = providerRetryAfter(event.headers); });
62
+
63
+ pi.on("agent_start", async (_event, ctx) => {
64
+ const record = pendingRetry;
65
+ pendingRetry = undefined;
66
+ if (!record) return;
67
+ const signal = ctx.signal;
68
+ if (!signal || signal.aborted || lifetime.signal.aborted) return;
69
+ const cancellation = AbortSignal.any([signal, lifetime.signal]);
70
+ const cancel = () => {
71
+ // 在取消事件的同步阶段清理,dispose 随后会使 ctx 失效,不能在 await 后再访问它。
72
+ lastTurn = undefined;
73
+ pendingRetry = undefined;
74
+ ctx.ui.setStatus("error-retry", undefined);
75
+ if (!signal.aborted) ctx.abort();
76
+ };
77
+ cancellation.addEventListener("abort", cancel, { once: true });
78
+ ctx.ui.setStatus("error-retry", `无限重试 #${record.retry},等待 ${(record.delayMs / 1000).toFixed(1)} 秒`);
79
+ try {
80
+ // 最终边界没有运行信号:把等待放到下一次 agent_start,才能被标准停止按钮取消。
81
+ if (!await waitForRetry(record.delayMs, cancellation)) return;
82
+ pi.appendEntry(RECOVERY_ENTRY, {
83
+ kind: "retry-started", retryId: record.retryId, retry: record.retry,
84
+ messageEntryId: record.messageEntryId,
85
+ });
86
+ } finally {
87
+ cancellation.removeEventListener("abort", cancel);
88
+ if (!cancellation.aborted) ctx.ui.setStatus("error-retry", undefined);
89
+ }
90
+ });
91
+
92
+ pi.on("turn_end", (event, ctx) => {
93
+ lastTurn = { event, responseDelay };
94
+ const message = event.message;
95
+ if (message.role !== "assistant" || message.stopReason !== "length" || event.outcome === "aborted" ||
96
+ ctx.signal?.aborted || lifetime.signal.aborted || event.continue || ctx.hasPendingMessages() ||
97
+ event.context.pendingMessages.length > 0) return;
98
+ // length 不是错误重试:保留回复和 Pi 的截断工具失败结果,追加明确续写提示。
99
+ // turn_end 的一次 continuation 与自然工具后续共用调度,不会额外请求两次。
100
+ return {
101
+ continue: true,
102
+ entries: [
103
+ {
104
+ type: "custom" as const, customType: RECOVERY_ENTRY,
105
+ data: { kind: "continue", messageEntryId: event.messageEntryId },
106
+ },
107
+ {
108
+ type: "custom_message" as const, customType: "pi-error-retry.continue",
109
+ content: CONTINUE_MESSAGE, display: true,
110
+ },
111
+ ],
112
+ };
113
+ });
114
+
115
+ pi.on("agent_before_settle", (event, ctx) => {
116
+ const turn = lastTurn?.event;
117
+ const message = turn?.message;
118
+ if (!turn || message?.role !== "assistant" || message.stopReason !== "error" || event.outcome !== "error" ||
119
+ lifetime.signal.aborted || event.continue || ctx.hasPendingMessages() ||
120
+ event.context.pendingMessages.length > 0) return;
121
+ // 内置有限重试与溢出恢复先行;已被其他恢复省略的失败不再接管。
122
+ if (!event.context.contextEntries.some((entry) => entry.sourceEntry.id === turn.messageEntryId)) return;
123
+ let key: string;
124
+ try {
125
+ const { config } = readConfig(configPath);
126
+ if (!config.rules.length || !matchesRules(config.rules, message.errorMessage ?? "")) return;
127
+ key = policyKey(config.rules);
128
+ } catch (error) {
129
+ ctx.ui.notify(`错误重试未接管:${error instanceof Error ? error.message : String(error)}`, "warning");
130
+ return;
131
+ }
132
+ const previous = previousRetry(ctx.sessionManager.getBranch(), key, message.provider, message.model);
133
+ const retry = (previous?.retry ?? 0) + 1;
134
+ const record: RetryRecord = {
135
+ kind: "retry", retryId: previous?.retryId ?? randomUUID(), retry, policyKey: key,
136
+ provider: message.provider, model: message.model, messageEntryId: turn.messageEntryId,
137
+ delayMs: retryDelay(retry, lastTurn?.responseDelay), errorMessage: message.errorMessage ?? "",
138
+ };
139
+ pendingRetry = record;
140
+ // 追加省略记录而非删除历史;原失败及用量保留,模型上下文回到失败前的请求。
141
+ // 没有“继续”或伪用户消息,因此不会从头重放已经完成的工具。
142
+ return {
143
+ continue: true,
144
+ entries: [
145
+ { type: "context_edit" as const, targetId: turn.messageEntryId, replacement: null },
146
+ { type: "custom" as const, customType: RECOVERY_ENTRY, data: record },
147
+ ],
148
+ };
149
+ });
150
+
151
+ pi.registerCommand("error-retry", {
152
+ description: "查看规则;edit 编辑,stop 停止,import <路径> 导入,export <路径> 导出",
153
+ handler: async (args, ctx) => {
154
+ const commandLifetime = lifetime;
155
+ if (commandLifetime.signal.aborted) return;
156
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(args.trim());
157
+ const action = match?.[1] ?? "";
158
+ const argument = match?.[2]?.trim() ?? "";
159
+ if (action === "stop" && !argument) {
160
+ pendingRetry = undefined;
161
+ ctx.abort();
162
+ return;
163
+ }
164
+ if ((action && !["edit", "import", "export"].includes(action)) ||
165
+ (action === "edit" && argument) || (["import", "export"].includes(action) && !argument)) {
166
+ ctx.ui.notify("用法:/error-retry [edit|stop|import <文件路径>|export <文件路径>]", "warning");
167
+ return;
168
+ }
169
+ try {
170
+ if (action === "import" || action === "export") {
171
+ const path = resolveRulesFilePath(argument, ctx.cwd);
172
+ if (action === "export") {
173
+ const config = exportRulesFile(configPath, path);
174
+ ctx.ui.notify(`已导出 ${config.rules.length} 条规则:${path}(DSH v1 格式)`, "info");
175
+ return;
176
+ }
177
+ if (!ctx.hasUI) {
178
+ ctx.ui.notify("当前模式无法确认替换规则,请在 TUI 或支持确认对话的 RPC 客户端导入", "warning");
179
+ return;
180
+ }
181
+ const snapshot = readConfig(configPath);
182
+ const incoming = readRulesFile(path);
183
+ const confirmed = await ctx.ui.confirm("导入错误重试规则",
184
+ `从 ${path} 导入 ${incoming.rules.length} 条规则,将替换当前 ${snapshot.config.rules.length} 条规则。保存不取消已排定的等待;无限重试可能产生持续请求和费用。是否继续?`,
185
+ { signal: commandLifetime.signal });
186
+ // 对话可能跨越 reload 或会话切换;旧命令不能继续写文件或通知新会话。
187
+ if (commandLifetime.signal.aborted) return;
188
+ if (!confirmed) {
189
+ ctx.ui.notify("已取消导入,规则未修改", "info");
190
+ return;
191
+ }
192
+ const config = saveConfig(configPath, incoming, snapshot.revision);
193
+ ctx.ui.notify(`已导入 ${config.rules.length} 条规则,替换原规则;下次错误判定自动生效`, "info");
194
+ return;
195
+ }
196
+ const snapshot = readConfig(configPath);
197
+ if (action !== "edit") {
198
+ ctx.ui.notify(`错误规则:${snapshot.config.rules.length} 条(命中后无限重试);长度截断:自动继续。配置:${configPath}`, "info");
199
+ return;
200
+ }
201
+ if (!ctx.hasUI) {
202
+ ctx.ui.notify(`当前模式没有编辑器,请手动编辑 ${configPath}`, "warning");
203
+ return;
204
+ }
205
+ let draft = JSON.stringify(snapshot.config, null, 2);
206
+ while (true) {
207
+ const edited = await ctx.ui.editor("错误重试规则(JSON);保存不会取消已排定的等待", draft);
208
+ if (edited === undefined) return;
209
+ draft = edited;
210
+ try {
211
+ const config = saveConfig(configPath, JSON.parse(draft), snapshot.revision);
212
+ ctx.ui.notify(`已保存 ${config.rules.length} 条规则;下次错误判定自动生效`, "info");
213
+ return;
214
+ } catch (error) {
215
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
216
+ // 保存失败保留草稿;发生版本冲突时不更新旧 revision,禁止覆盖他人的修改。
217
+ const choice = await ctx.ui.select("规则尚未保存", ["继续修改草稿", "放弃草稿并读取最新配置", "取消"]);
218
+ if (choice === "继续修改草稿") continue;
219
+ if (choice !== "放弃草稿并读取最新配置") return;
220
+ const latest = readConfig(configPath);
221
+ snapshot.revision = latest.revision;
222
+ draft = JSON.stringify(latest.config, null, 2);
223
+ }
224
+ }
225
+ } catch (error) {
226
+ if (!commandLifetime.signal.aborted) {
227
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
228
+ }
229
+ }
230
+ },
231
+ });
232
+ }
233
+
234
+ export default function (pi: ExtensionAPI): void {
235
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
236
+ registerErrorRetry(pi, join(agentDir, "error-retry.json"));
237
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "pi-error-retry",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "按规则无限重试 Pi 模型错误,并在长度截断时自动继续",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/librola/pi-extensions-shuyuan.git",
9
+ "directory": "pi-error-retry"
10
+ },
11
+ "homepage": "https://github.com/librola/pi-extensions-shuyuan/tree/main/pi-error-retry#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/librola/pi-extensions-shuyuan/issues"
14
+ },
15
+ "files": [
16
+ "CHANGELOG.md",
17
+ "index.ts",
18
+ "src/",
19
+ "examples/"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://registry.npmjs.org"
24
+ },
25
+ "keywords": [
26
+ "pi-package"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./index.ts"
31
+ ]
32
+ },
33
+ "engines": {
34
+ "node": ">=22.19.0"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test tests/*.test.mjs",
38
+ "check": "node scripts/check.mjs"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-coding-agent": "*"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "24.10.1",
45
+ "typescript": "5.9.3"
46
+ }
47
+ }
package/src/config.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { type RetryConfig, validateConfig } from "./rules.ts";
5
+
6
+ export interface ConfigSnapshot {
7
+ config: RetryConfig;
8
+ // 使用文件原文检测其他窗口的保存,缺失文件与空文件必须区分。
9
+ revision: string | null;
10
+ }
11
+
12
+ export function readConfig(path: string): ConfigSnapshot {
13
+ let revision: string | null;
14
+ try {
15
+ revision = readFileSync(path, "utf8");
16
+ } catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
18
+ revision = null;
19
+ }
20
+ return { config: validateConfig(revision === null ? {} : JSON.parse(revision)), revision };
21
+ }
22
+
23
+ export function saveConfig(path: string, value: unknown, expectedRevision: string | null): RetryConfig {
24
+ const config = validateConfig(value);
25
+ mkdirSync(dirname(path), { recursive: true });
26
+ const lockPath = `${path}.lock`;
27
+ let lock: number;
28
+ try {
29
+ lock = openSync(lockPath, "wx", 0o600);
30
+ } catch (error) {
31
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("其他窗口正在保存,请稍后重试");
32
+ throw error;
33
+ }
34
+ const temporary = `${path}.${randomUUID()}.tmp`;
35
+ try {
36
+ if (readConfig(path).revision !== expectedRevision) {
37
+ throw new Error("规则已被其他窗口修改;草稿未保存,请读取最新配置后重新编辑");
38
+ }
39
+ writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { flag: "wx", mode: 0o600 });
40
+ renameSync(temporary, path);
41
+ return config;
42
+ } finally {
43
+ try {
44
+ unlinkSync(temporary);
45
+ } catch (error) {
46
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
47
+ } finally {
48
+ closeSync(lock);
49
+ unlinkSync(lockPath);
50
+ }
51
+ }
52
+ }
package/src/rules.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { Script, createContext } from "node:vm";
2
+
3
+ export type RetryRule =
4
+ | { mode: "contains"; pattern: string }
5
+ | { mode: "regex"; pattern: string; flags?: string };
6
+
7
+ export interface RetryConfig {
8
+ rules: RetryRule[];
9
+ }
10
+
11
+ export const DEFAULT_RULES: readonly RetryRule[] = [
12
+ {
13
+ mode: "contains",
14
+ pattern:
15
+ "Requests have exceeded the throughput limit on your Provisioned-Managed deployment. If you continue to exceed your limit, consider increasing the number of provisioned throughput units deployed.",
16
+ },
17
+ {
18
+ mode: "contains",
19
+ pattern: "Your request exceeds the maximum size allowed by your deployment's quota.",
20
+ },
21
+ ];
22
+
23
+ export function validateConfig(value: unknown): RetryConfig {
24
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
25
+ throw new Error("配置必须是 JSON 对象");
26
+ }
27
+ const object = value as Record<string, unknown>;
28
+ if (Object.keys(object).some((key) => key !== "rules")) throw new Error("配置仅接受 rules 字段");
29
+ if (object.rules === undefined) return { rules: DEFAULT_RULES.map((rule) => ({ ...rule })) };
30
+ if (!Array.isArray(object.rules)) throw new Error("rules 必须是数组");
31
+ const rules = object.rules.map((value: unknown, index): RetryRule => {
32
+ const prefix = `规则 ${index + 1}:`;
33
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${prefix}必须是对象`);
34
+ const rule = value as Record<string, unknown>;
35
+ if (Object.keys(rule).some((key) => !["mode", "pattern", "flags"].includes(key))) {
36
+ throw new Error(`${prefix}仅接受 mode、pattern、flags`);
37
+ }
38
+ if (typeof rule.pattern !== "string" || !/\S/.test(rule.pattern)) {
39
+ throw new Error(`${prefix}pattern 不能为空`);
40
+ }
41
+ if (rule.mode === "contains") {
42
+ if (rule.flags !== undefined) throw new Error(`${prefix}contains 不接受 flags`);
43
+ return { mode: "contains", pattern: rule.pattern };
44
+ }
45
+ if (rule.mode !== "regex") throw new Error(`${prefix}mode 必须是 contains 或 regex`);
46
+ if (rule.flags !== undefined && (typeof rule.flags !== "string" || !/^[imsu]*$/.test(rule.flags))) {
47
+ throw new Error(`${prefix}flags 仅支持 i/m/s/u`);
48
+ }
49
+ const flags = rule.flags as string | undefined;
50
+ try {
51
+ new RegExp(rule.pattern, flags);
52
+ } catch (error) {
53
+ throw new Error(`${prefix}${error instanceof Error ? error.message : String(error)}`);
54
+ }
55
+ return { mode: "regex", pattern: rule.pattern, ...(flags === undefined ? {} : { flags }) };
56
+ });
57
+ return { rules };
58
+ }
59
+
60
+ export function policyKey(rules: RetryRule[]): string {
61
+ // OR 规则的排序、重复项和正则标志顺序不改变策略;有效空白不能丢失。
62
+ const normalized = rules.map((rule) => JSON.stringify(rule.mode === "contains"
63
+ ? rule
64
+ : { mode: rule.mode, pattern: rule.pattern, flags: new RegExp(rule.pattern, rule.flags).flags }));
65
+ return JSON.stringify(["pi-error-retry", 1, [...new Set(normalized)].sort()]);
66
+ }
67
+
68
+ const matchScript = new Script(`rules.some(rule => rule.mode === "contains"
69
+ ? message.includes(rule.pattern)
70
+ : new RegExp(rule.pattern, rule.flags).test(message))`);
71
+
72
+ export function matchesRules(rules: RetryRule[], message: string): boolean {
73
+ // 整组规则共享 25ms 预算;异常由调用方交回 Pi,不把超时当成命中。
74
+ return matchScript.runInContext(createContext({ rules, message }), { timeout: 25 }) === true;
75
+ }
76
+
77
+ export function retryDelay(retry: number, providerDelay?: number, random = Math.random): number {
78
+ if (providerDelay !== undefined && Number.isFinite(providerDelay) && providerDelay > 0) return providerDelay;
79
+ const base = Math.min(1000 * 2 ** Math.min(retry - 1, 5), 30000);
80
+ return Math.min(30000, Math.round(base * (0.9 + random() * 0.2)));
81
+ }
82
+
83
+ export function providerRetryAfter(headers: Record<string, string>, now = Date.now()): number | undefined {
84
+ const normalized = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
85
+ const millis = normalized["retry-after-ms"];
86
+ if (millis !== undefined && millis.trim() && Number.isFinite(Number(millis)) && Number(millis) > 0) {
87
+ return Number(millis);
88
+ }
89
+ const value = normalized["retry-after"];
90
+ if (!value?.trim()) return undefined;
91
+ const seconds = Number(value);
92
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - now;
93
+ return Number.isFinite(delay) && delay > 0 ? delay : undefined;
94
+ }
95
+
96
+ export async function waitForRetry(delayMs: number, signal: AbortSignal): Promise<boolean> {
97
+ let remaining = delayMs;
98
+ while (remaining > 0) {
99
+ if (signal.aborted) return false;
100
+ // 服务端长等待不截短,分段避免 Node 定时器溢出。
101
+ const slice = Math.min(remaining, 2147483647);
102
+ const completed = await new Promise<boolean>((resolve) => {
103
+ const finish = (result: boolean) => {
104
+ clearTimeout(timer);
105
+ signal.removeEventListener("abort", abort);
106
+ resolve(result);
107
+ };
108
+ const abort = () => finish(false);
109
+ const timer = setTimeout(() => finish(true), slice);
110
+ signal.addEventListener("abort", abort, { once: true });
111
+ });
112
+ if (!completed) return false;
113
+ remaining -= slice;
114
+ }
115
+ return !signal.aborted;
116
+ }
@@ -0,0 +1,96 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, linkSync, openSync, readSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { readConfig } from "./config.ts";
6
+ import { type RetryConfig, validateConfig } from "./rules.ts";
7
+
8
+ const MAX_RULES_FILE_BYTES = 1024 * 1024;
9
+
10
+ /** 将命令剩余参数作为一个路径,不执行 shell 或展开环境变量。 */
11
+ export function resolveRulesFilePath(argument: string, cwd: string): string {
12
+ let path = argument.trim();
13
+ if (path.startsWith('"') || path.startsWith("'")) {
14
+ if (path.length < 2 || path.at(-1) !== path[0]) throw new Error("文件路径的引号必须成对");
15
+ path = path.slice(1, -1);
16
+ }
17
+ if (!path.trim() || path.includes("\0")) throw new Error("请提供有效的 JSON 文件路径");
18
+ if (path === "~") path = homedir();
19
+ else if (path.startsWith("~/")) path = join(homedir(), path.slice(2));
20
+ return resolve(cwd, path);
21
+ }
22
+
23
+ /** 读取 DSH v1、原生配置对象或裸规则数组,统一使用现有规则校验。 */
24
+ export function readRulesFile(path: string): RetryConfig {
25
+ // 非阻塞打开后检查同一个描述符,拒绝 FIFO、设备等可能让命令一直等待的输入。
26
+ const file = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
27
+ let text: string;
28
+ try {
29
+ const stat = fstatSync(file);
30
+ if (!stat.isFile()) throw new Error("导入路径必须是普通 JSON 文件");
31
+ if (stat.size > MAX_RULES_FILE_BYTES) throw new Error("规则文件过大(超过 1 MiB)");
32
+ // 读取上限不依赖最初的 stat,文件并发增长也不会无限分配内存。
33
+ const buffer = Buffer.alloc(MAX_RULES_FILE_BYTES + 1);
34
+ let size = 0;
35
+ while (size < buffer.length) {
36
+ const count = readSync(file, buffer, size, buffer.length - size, null);
37
+ if (count === 0) break;
38
+ size += count;
39
+ }
40
+ if (size > MAX_RULES_FILE_BYTES) throw new Error("规则文件过大(超过 1 MiB)");
41
+ text = buffer.subarray(0, size).toString("utf8").replace(/^\uFEFF/, "");
42
+ } finally {
43
+ closeSync(file);
44
+ }
45
+ let value: unknown;
46
+ try {
47
+ value = JSON.parse(text);
48
+ } catch (error) {
49
+ throw new Error(`JSON 解析失败:${error instanceof Error ? error.message : String(error)}`);
50
+ }
51
+ if (Array.isArray(value)) return validateConfig({ rules: value });
52
+ if (!value || typeof value !== "object") throw new Error("规则文件必须包含 rules 数组或直接提供规则数组");
53
+ const object = value as Record<string, unknown>;
54
+ if (Object.keys(object).some((key) => key !== "version" && key !== "rules")) {
55
+ throw new Error("规则文件仅接受 version、rules 字段");
56
+ }
57
+ if (object.version !== undefined && object.version !== 1) throw new Error("不支持的规则文件版本,仅支持 version: 1");
58
+ // 导入缺少 rules 的文件不能意外启用默认规则;用户配置读取的旧行为不变。
59
+ if (!Array.isArray(object.rules)) throw new Error("规则文件必须包含 rules 数组");
60
+ return validateConfig({ rules: object.rules });
61
+ }
62
+
63
+ /** 导出当前有效规则为 DSH v1 文件,原子创建新文件,绝不覆盖已有目标。 */
64
+ export function exportRulesFile(configPath: string, destination: string): RetryConfig {
65
+ const config = readConfig(configPath).config;
66
+ const target = join(realpathSync(dirname(destination)), basename(destination));
67
+ let active = resolve(configPath);
68
+ try {
69
+ active = join(realpathSync(dirname(active)), basename(active));
70
+ } catch (error) {
71
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
72
+ }
73
+ if (target === active) throw new Error("不能导出到正在使用的配置文件,请选择其他路径");
74
+ const rules = config.rules.map((rule) => rule.mode === "regex" ? { ...rule, flags: rule.flags ?? "" } : { ...rule });
75
+ const text = `${JSON.stringify({ version: 1, rules }, null, 2)}\n`;
76
+ if (Buffer.byteLength(text) > MAX_RULES_FILE_BYTES) throw new Error("导出规则文件过大(超过 1 MiB)");
77
+ const temporary = `${target}.${randomUUID()}.tmp`;
78
+ const file = openSync(temporary, "wx", 0o600);
79
+ try {
80
+ try {
81
+ writeFileSync(file, text);
82
+ } finally {
83
+ closeSync(file);
84
+ }
85
+ // 同目录硬链接一次发布完整内容;目标在检查后被创建也不会被覆盖。
86
+ try {
87
+ linkSync(temporary, target);
88
+ } catch (error) {
89
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`导出目标已存在,未覆盖:${destination}`);
90
+ throw error;
91
+ }
92
+ } finally {
93
+ unlinkSync(temporary);
94
+ }
95
+ return config;
96
+ }