dsh-llm-fallbacks 0.3.3 → 0.3.5
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 +7 -5
- package/README.zh-CN.md +7 -5
- package/dist/client/index.js +45 -13
- package/dist/commands.d.ts +4 -0
- package/dist/config.d.ts +14 -0
- package/dist/cooldown.d.ts +9 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +161 -15
- package/dist/recovery.d.ts +92 -0
- package/dist/state.d.ts +27 -2
- package/dist/time-slots.d.ts +3 -3
- package/package.json +18 -18
package/README.md
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | [中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-llm-fallbacks)
|
|
5
6
|
[](LICENSE)
|
|
6
7
|

|
|
7
8
|

|
|
8
9
|

|
|
9
|
-

|
|
10
11
|
[](https://dshfind.com/zh/plugins/omdsh-dev/dsh-llm-fallbacks?ref=badge)
|
|
11
12
|
|
|
12
13
|
Automatic provider/model fallback chains for dsh (DeepSeek Harness): when an agent's LLM requests keep failing — retries exhausted, auth errors, quota exceeded, rate limiting (429) — the plugin switches provider/model along the fallback chain for the current role, and the current step/turn continues on the target model: tasks are not interrupted by model problems.
|
|
@@ -23,7 +24,7 @@ Four frozen UTC+8 presets (windows are code constants; preset rows lock `tz` to
|
|
|
23
24
|
|
|
24
25
|
| Preset | Window |
|
|
25
26
|
|---|---|
|
|
26
|
-
| `liang-peak` | 09:00–12:00 and 14:00–18:00
|
|
27
|
+
| `liang-peak` | Monday–Friday 09:00–12:00 and 14:00–18:00 |
|
|
27
28
|
| `liang-valley` | every other UTC+8 time (complement of Liang Peak) |
|
|
28
29
|
| `glm-peak` | Monday–Friday 14:00–18:00 |
|
|
29
30
|
| `glm-valley` | every other time (complement of GLM Peak) |
|
|
@@ -81,7 +82,7 @@ fallbacks:
|
|
|
81
82
|
- deepseek-official/deepseek-v4-flash # last resort (Flash or Pro)
|
|
82
83
|
timeSlots: # optional: rotate the effective root chain by wall-clock windows
|
|
83
84
|
- kind: preset # frozen UTC+8 window; only the chain is editable
|
|
84
|
-
preset: liang-peak # 09:00–12:00 and 14:00–18:00
|
|
85
|
+
preset: liang-peak # Monday–Friday 09:00–12:00 and 14:00–18:00
|
|
85
86
|
chain:
|
|
86
87
|
- anthropic/claude-3-5-sonnet
|
|
87
88
|
- kind: custom # custom window (may wrap midnight)
|
|
@@ -130,6 +131,7 @@ Save the config and restart the session, then type `/fallbacks` — the read-onl
|
|
|
130
131
|
- **Time slots**: optional `fallbacks.timeSlots` rows rotate the effective root chain by wall-clock windows in the config-level `tz` timezone (default `Asia/Shanghai`) — four frozen UTC+8 presets (`liang-peak` / `liang-valley` / `glm-peak` / `glm-valley`, windows are code constants, models-only edits) or custom `start`/`end`/`days` windows. The first matching row wins; the all-day row is always last. A slot change applies on the **next** root request and is logged as a **time-slot switch** — a routing seed, never a failure decision: it consumes no cooldown and does not count against `maxSwitchesPerStep`. Failure walks keep the **fallback switch** copy (see [Time-slot presets](#time-slot-presets)).
|
|
131
132
|
- **Dispatch-time role resolution**: on a subagent's first request its role is resolved in three stages — explicit (`agentPreset` matches a declared role id) → deterministic rules (unchanged) → LLM auto-match from the declared role taxonomy (`fallbacks.roleAutoMatch`, default `true`). The resolved role's chain-head model is injected into the first request and recorded via an explicit `role → model` log line (no durable `fallbacks/switch` event is written — issue #52 stop-write); set `roleAutoMatch: false` to disable the LLM auto-match stage (the explicit `agentPreset` stage still applies — with no explicit role this reproduces the previous rules-only behavior). The settings card always renders an **Enable role auto-match** switch (default `true`) to toggle it — the schema default applies even to legacy configs that never declared the key.
|
|
132
133
|
- **Cooldown and revert**: failed / switched-away models are not re-selected during cooldown; `revertPolicy: cooldown-expiry` returns to the primary model automatically.
|
|
134
|
+
- **Half-open recovery (opt-in)**: `recovery: half-open` makes recovery evidence-driven — an expired cooldown leaves the route half-open for one logged probe instead of restoring the preference; consecutive failures escalate the suppression duration (×2 per failure, capped at 1 h); an observed completion closes the circuit and fully restores the preference. `revertPolicy: 'never'` keeps the mechanism inert; state is session-scoped in-memory (a restart resets). YAML-only — the default `timer` keeps every existing behavior byte-identical (see [docs/configuration.md](docs/configuration.md#recovery-mode-recovery-key)).
|
|
133
135
|
- **Visible behavior**: every switch is recorded in an info-level log line (from/to/role/reason) — no silent model switching. The plugin deliberately writes **no** durable `fallbacks/switch` session events (issue #52: the apply()-time event-type registration was proven ineffective, and a session containing the event refused to load after a dsh restart). Sessions written by older plugin versions that contain such events are repaired by `scripts/repair-fallbacks-switch-logs.ts`, which marks legacy events ignorable so affected sessions load again.
|
|
134
136
|
- **Safety valves**: `maxSwitchesPerStep` caps switches per step and `alwaysModeRetryCap` caps always-mode retries — chain loops cannot amplify latency.
|
|
135
137
|
- **No-config no-op**: with no chains configured the plugin behaves exactly like not being installed (`enabled` is off by default — see [Minimal configuration](#minimal-configuration)).
|
|
@@ -138,7 +140,7 @@ Save the config and restart the session, then type `/fallbacks` — the read-onl
|
|
|
138
140
|
|
|
139
141
|
In a dsh-tui profile the plugin has three operator surfaces, with a strict duty split:
|
|
140
142
|
|
|
141
|
-
- **`/fallbacks`** — what happened this session: origin, resolved role, effective chain, recent fallback switches, cooldown status. Read-only.
|
|
143
|
+
- **`/fallbacks`** — what happened this session: origin, resolved role, effective chain, recent fallback switches, cooldown status (half-open marker rows when `recovery: half-open` is active). Read-only.
|
|
142
144
|
- **`/fallbacks config`** — what is configured: composed-config readback (trigger codes, root chain, time slots, timezone, roles, role rules, cooldown, revert policy, safety valves, presets, role auto-match). Read-only apart from the one action command **`/fallbacks config revert-seed <role-id>`**, which restores a seeded role's persona to its declared seed default (a web-card action the settings seam cannot express).
|
|
143
145
|
- **`/settings`** — the edit surface. The plugin registers a **fallbacks** section with full parity to the web settings card: booleans (`enabled`, `roleAutoMatch`) render as toggles, selects (`presets`, `revertPolicy`) as pickers, and numbers (`cooldownMs`, `maxSwitchesPerStep`, `alwaysModeRetryCap`) as numeric inputs; complex structures (`rootChain`, `timeSlots`, `roles.list`, `roles.rules`) are JSON text fields and `triggerCodes` a comma-separated text field. Invalid drafts (bad JSON, non-conforming chains, malformed time-slot rows) block the save — the section never corrupts the config.
|
|
144
146
|
|
|
@@ -167,7 +169,7 @@ Notes:
|
|
|
167
169
|
Time slots are introduced in the [featured overview](#time-slots) above; this section is the reference. Time-slot rows rotate the **effective root chain** by wall-clock windows — useful for peak/valley pricing without confusing wall-clock rotation with failure fallback. The copy split is strict: slot rotation logs and UI say **time-slot switch**; the failure walk keeps **fallback switch**; the conversation notice Model downgraded stays on the failure path only.
|
|
168
170
|
|
|
169
171
|
- **Match order**: at every root request, the first extra row whose window contains the current moment (in `fallbacks.tz`, default `Asia/Shanghai` / UTC+8) wins — that row's chain **replaces** the all-day chain. No row matches → the all-day `rootChain` is used. The all-day row is always last and **required**: its last entry must be exactly one official V4 model (Flash XOR Pro; leading Default fallback chain entries are walked first).
|
|
170
|
-
- **Presets** (frozen, not user-editable): `liang-peak` = 09:00–12:00 **and** 14:00–18:00
|
|
172
|
+
- **Presets** (frozen, not user-editable): `liang-peak` = Monday–Friday 09:00–12:00 **and** 14:00–18:00; `liang-valley` = every other UTC+8 time; `glm-peak` = Monday–Friday 14:00–18:00; `glm-valley` = every other time. One preset id = one row; the card picker never offers a duplicate.
|
|
171
173
|
- **Custom rows**: `start` / `end` (`HH:mm`, may wrap midnight) + optional `days` (0=Sunday…6=Saturday; omitted/empty = every day) + models.
|
|
172
174
|
- **Next-request apply**: a slot boundary crossing never preempts an in-flight step — the new row takes effect on the next root request. Rotation is mount-only: info log + card/`/fallbacks` status line, no durable switch event.
|
|
173
175
|
- **Settings card**: the Main agent section groups Time slots (extra rows — add preset / add custom / remove / reorder by buttons or **drag**; preset rows show a read-only window summary and edit models only; custom rows carry an editable name; the **timezone picker** lives here and **locks to Asia/Shanghai while any preset row exists**, since preset windows are frozen UTC+8 constants), Default fallback chain (walked first when no slot matches) and Default model (the official V4 Flash | Pro last-resort fallback). Rows are collapsible to name + first model. There is no `timeSlots.enabled` master switch (adding a row is the opt-in) and no `rootMode` control.
|
package/README.zh-CN.md
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | [中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-llm-fallbacks)
|
|
5
6
|
[](LICENSE)
|
|
6
7
|

|
|
7
8
|

|
|
8
9
|

|
|
9
|
-

|
|
10
11
|
[](https://dshfind.com/zh/plugins/omdsh-dev/dsh-llm-fallbacks?ref=badge)
|
|
11
12
|
|
|
12
13
|
dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subagent 的模型请求持续失败(重试耗尽、权限、配额超限、限流 429)时,按角色/模型 fallback 链自动切换 provider/model,当前 step/turn 在目标模型上继续完成——任务不因模型问题中断。
|
|
@@ -23,7 +24,7 @@ dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subage
|
|
|
23
24
|
|
|
24
25
|
| 预设 | 窗口 |
|
|
25
26
|
|---|---|
|
|
26
|
-
| `liang-peak` |
|
|
27
|
+
| `liang-peak` | 周一至周五 09:00–12:00 与 14:00–18:00 |
|
|
27
28
|
| `liang-valley` | 其它所有 UTC+8 时间(Liang Peak 的补集) |
|
|
28
29
|
| `glm-peak` | 周一至周五 14:00–18:00 |
|
|
29
30
|
| `glm-valley` | 其余时间(GLM Peak 的补集) |
|
|
@@ -81,7 +82,7 @@ fallbacks:
|
|
|
81
82
|
- deepseek-official/deepseek-v4-flash # 最后一档(Flash 或 Pro)
|
|
82
83
|
timeSlots: # 可选:按墙钟窗口轮换生效 root 链
|
|
83
84
|
- kind: preset # 冻结的 UTC+8 窗口;仅链可编辑
|
|
84
|
-
preset: liang-peak #
|
|
85
|
+
preset: liang-peak # 周一至周五 09:00–12:00 与 14:00–18:00
|
|
85
86
|
chain:
|
|
86
87
|
- anthropic/claude-3-5-sonnet
|
|
87
88
|
- kind: custom # 自定义窗口(可跨午夜)
|
|
@@ -130,6 +131,7 @@ fallbacks:
|
|
|
130
131
|
- **峰谷无忧(分时切换)**:可选的 `fallbacks.timeSlots` 行按墙钟窗口(配置级 `tz` 时区,默认 `Asia/Shanghai`)轮换 root 生效链——四个冻结的 UTC+8 预设(`liang-peak` / `liang-valley` / `glm-peak` / `glm-valley`,窗口为代码常量、仅模型链可编辑),或自定义 `start`/`end`/`days` 窗口。第一条命中的行生效;全时段行固定最后。时段切换在**下一个** root 请求生效,日志记为**分时切换**——路由种子而非失败决策:不消耗冷却、不计入 `maxSwitchesPerStep`。失败降级保留**降级切换**文案(见 [分时槽预设(分时切换)](#分时槽预设分时切换))。
|
|
131
132
|
- **派发时角色解析**:在 subagent 的首次请求上,其角色按三个阶段解析——显式(`agentPreset` 匹配已声明角色 id)→ 确定性规则(不变)→ LLM 自动匹配(从已声明角色体系中选择,`fallbacks.roleAutoMatch` 默认 `true`)。解析出的角色的链头模型注入首次请求,并以显式 `role → model` 日志行记录(不写 durable `fallbacks/switch` 事件——issue #52 停写);设 `roleAutoMatch: false` 仅关闭 LLM 自动匹配阶段(显式 `agentPreset` 阶段仍生效——无显式角色时即复现原有仅规则行为)。设置卡总是渲染「启用角色自动匹配」开关(默认 `true`)以切换之——即使是从未声明过该键的旧配置,schema 默认值同样生效。
|
|
132
133
|
- **冷却与回主**:被切离/失败的模型在冷却期内不再入选;`revertPolicy: cooldown-expiry` 冷却到期后自动回主模型。
|
|
134
|
+
- **半开恢复(可选)**:`recovery: half-open` 让恢复以证据驱动——冷却到期后路由进入 **half-open**,以一次记录探针(logged probe)放行,而不是直接恢复首选;连续失败使抑制时长按 **×2** 逐次升级、**1 小时**封顶;观察到完成即闭合回路、完全恢复首选。`revertPolicy: 'never'` 使该机制完全失效;状态为会话级内存态(重启即重置)。仅 YAML 配置——默认 `timer` 保持所有既有行为逐字节一致(见 [docs/configuration.md](docs/configuration.md#recovery-mode-recovery-key))。
|
|
133
135
|
- **行为可见**:每次切换以 info 级日志行(from/to/role/reason)记录——无静默换模型。插件**刻意不写** durable `fallbacks/switch` 会话事件(issue #52——apply() 时的事件类型注册被证伪无效,含该事件的会话在 dsh 重启后拒绝加载)。由旧版插件写入、含此类事件的会话由 `scripts/repair-fallbacks-switch-logs.ts` 修复——旧事件被标记 ignorable 后,受影响会话可重新加载。
|
|
134
136
|
- **安全阀**:`maxSwitchesPerStep` 限制每 step 切换次数、`alwaysModeRetryCap` 限制 always 模式重试——链循环不会放大延迟。
|
|
135
137
|
- **无配置回归(no-op)**:未配置任何链时行为与未安装插件完全一致——`enabled` 默认关闭(见 [最小配置](#最小配置))。
|
|
@@ -138,7 +140,7 @@ fallbacks:
|
|
|
138
140
|
|
|
139
141
|
在 dsh-tui profile 中,插件有三个操作面——职责严格区分:
|
|
140
142
|
|
|
141
|
-
- **`/fallbacks`** ——
|
|
143
|
+
- **`/fallbacks`** —— 本次会话发生了什么:来源、解析角色、生效链、最近降级切换、冷却状态(`recovery: half-open` 生效时显示 half-open 标记行)。只读。
|
|
142
144
|
- **`/fallbacks config`** —— 配置了什么:组合配置回读(触发码、根链、分时槽、时区、角色、角色规则、冷却、回主策略、安全阀、预置、角色自动匹配)。除唯一的动作命令 **`/fallbacks config revert-seed <role-id>`** 外只读——该命令把某个 seed 角色的 persona 还原为已声明的默认(设置 seam 无法表达 Web 卡的这类动作能力)。
|
|
143
145
|
- **`/settings`** —— 编辑界面。插件注册 **fallbacks** 区块,与 **Web 设置卡完全一致**:布尔(`enabled`、`roleAutoMatch`)渲染为开关、下拉(`presets`、`revertPolicy`)为选择器、数值(`cooldownMs`、`maxSwitchesPerStep`、`alwaysModeRetryCap`)为数字输入;复杂结构(`rootChain`、`timeSlots`、`roles.list`、`roles.rules`)为 JSON 文本字段,`triggerCodes` 为逗号分隔文本字段。非法草稿(JSON 解析失败、链尾不合规、分时行畸形)会阻止保存——区块绝不写入损坏配置。
|
|
144
146
|
|
|
@@ -167,7 +169,7 @@ fallbacks:
|
|
|
167
169
|
峰谷无忧在[首页专题](#峰谷无忧)中介绍,本节是完整参考。分时槽行按墙钟窗口轮换**生效 root 链**——适合按峰谷切换模型,且不会把墙钟轮换误认为故障降级。文案严格区分:时段轮换的日志与 UI 用**分时切换**;失败降级保持**降级切换**;会话内「模型已降级」提示只出现在失败路径。
|
|
168
170
|
|
|
169
171
|
- **匹配顺序**:每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 `Asia/Shanghai` / UTC+8)的额外行生效——该行的模型链**取代**全时段链;无行命中则用全时段 `rootChain`。全时段行固定最后且**必选**:最后一项必须是恰好一个官方 V4 模型(Flash 或 Pro;前面的降级条目先走)。
|
|
170
|
-
- **预设**(冻结,不可编辑窗口):`liang-peak` =
|
|
172
|
+
- **预设**(冻结,不可编辑窗口):`liang-peak` = 周一至周五 09:00–12:00 **与** 14:00–18:00;`liang-valley` = 其它所有 UTC+8 时间;`glm-peak` = 周一至周五 14:00–18:00;`glm-valley` = 其余时间。一个预设 id 对应一行;设置卡的选择器不会重复提供已添加的预设。
|
|
171
173
|
- **自定义行**:`start` / `end`(`HH:mm`,可跨午夜)+ 可选 `days`(0=周日…6=周六;缺省/空 = 每天)+ 模型。
|
|
172
174
|
- **下一请求生效**:时段边界跨越绝不打断进行中的 step——新行在下一个 root 请求生效。轮换仅挂载生效:info 日志 + 设置卡/`/fallbacks` 状态行,无 durable 切换事件。
|
|
173
175
|
- **设置卡**:主代理区块下分三块——**分时槽设置**(额外行:添加预设 / 添加自定义 / 删除 / 按钮或**拖拽**排序;预设行只读展示窗口摘要、仅可编辑模型链;自定义行带可编辑名称;**时区选择器**在此区块内,只要存在预设行就**锁定 Asia/Shanghai**——预设窗口是冻结的 UTC+8 常量)、**默认降级链**(all-day 链,可配置的 provider/model 选择器列表)与**默认模型**(官方 V4 Flash | Pro 二选一链头)。行可折叠为「名称 + 首个模型」。没有 `timeSlots.enabled` 总开关(添加行即开启),也没有 `rootMode` 控件。
|
package/dist/client/index.js
CHANGED
|
@@ -199,18 +199,32 @@ window.__ModuleLoader__.load({
|
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
201
|
/**
|
|
202
|
-
* Frozen preset windows (UTC+8). `liang
|
|
203
|
-
*
|
|
204
|
-
*
|
|
202
|
+
* Frozen preset windows (UTC+8). `liang-peak` and `glm-peak` are
|
|
203
|
+
* Monday–Friday only (day mask `[1, 2, 3, 4, 5]`); their valleys are the
|
|
204
|
+
* `complement: true` of the corresponding peak (weekends included).
|
|
205
205
|
*/
|
|
206
206
|
const PRESETS = {
|
|
207
207
|
"liang-peak": {
|
|
208
208
|
windows: [{
|
|
209
209
|
start: "09:00",
|
|
210
|
-
end: "12:00"
|
|
210
|
+
end: "12:00",
|
|
211
|
+
days: [
|
|
212
|
+
1,
|
|
213
|
+
2,
|
|
214
|
+
3,
|
|
215
|
+
4,
|
|
216
|
+
5
|
|
217
|
+
]
|
|
211
218
|
}, {
|
|
212
219
|
start: "14:00",
|
|
213
|
-
end: "18:00"
|
|
220
|
+
end: "18:00",
|
|
221
|
+
days: [
|
|
222
|
+
1,
|
|
223
|
+
2,
|
|
224
|
+
3,
|
|
225
|
+
4,
|
|
226
|
+
5
|
|
227
|
+
]
|
|
214
228
|
}],
|
|
215
229
|
complement: false,
|
|
216
230
|
label: "Liang Peak"
|
|
@@ -218,10 +232,24 @@ window.__ModuleLoader__.load({
|
|
|
218
232
|
"liang-valley": {
|
|
219
233
|
windows: [{
|
|
220
234
|
start: "09:00",
|
|
221
|
-
end: "12:00"
|
|
235
|
+
end: "12:00",
|
|
236
|
+
days: [
|
|
237
|
+
1,
|
|
238
|
+
2,
|
|
239
|
+
3,
|
|
240
|
+
4,
|
|
241
|
+
5
|
|
242
|
+
]
|
|
222
243
|
}, {
|
|
223
244
|
start: "14:00",
|
|
224
|
-
end: "18:00"
|
|
245
|
+
end: "18:00",
|
|
246
|
+
days: [
|
|
247
|
+
1,
|
|
248
|
+
2,
|
|
249
|
+
3,
|
|
250
|
+
4,
|
|
251
|
+
5
|
|
252
|
+
]
|
|
225
253
|
}],
|
|
226
254
|
complement: true,
|
|
227
255
|
label: "Liang Valley"
|
|
@@ -458,7 +486,8 @@ window.__ModuleLoader__.load({
|
|
|
458
486
|
presets: "bundled",
|
|
459
487
|
roleAutoMatch: true,
|
|
460
488
|
timeSlots: [],
|
|
461
|
-
tz: "Asia/Shanghai"
|
|
489
|
+
tz: "Asia/Shanghai",
|
|
490
|
+
recovery: "timer"
|
|
462
491
|
};
|
|
463
492
|
/**
|
|
464
493
|
* Reserved role id: legal as a rule target (`roles.rules[].role`) and as
|
|
@@ -615,6 +644,8 @@ window.__ModuleLoader__.load({
|
|
|
615
644
|
}))) throw new TypeError("fallbacks descriptor timeSlots must be an array of slot rows (kind/preset/start/end strings, chain string array, days number array)");
|
|
616
645
|
const tz = value.tz;
|
|
617
646
|
if (tz !== void 0 && typeof tz !== "string") throw new TypeError("fallbacks descriptor tz must be a string");
|
|
647
|
+
const recovery = value.recovery;
|
|
648
|
+
if (recovery !== void 0 && recovery !== "timer" && recovery !== "half-open") throw new TypeError("fallbacks descriptor recovery must be timer|half-open");
|
|
618
649
|
return {
|
|
619
650
|
enabled: enabled ?? defaultFallbacksConfig.enabled,
|
|
620
651
|
triggerCodes: triggerCodes ?? [...defaultFallbacksConfig.triggerCodes],
|
|
@@ -630,7 +661,8 @@ window.__ModuleLoader__.load({
|
|
|
630
661
|
presets: presets ?? defaultFallbacksConfig.presets,
|
|
631
662
|
roleAutoMatch: roleAutoMatch ?? defaultFallbacksConfig.roleAutoMatch,
|
|
632
663
|
timeSlots: timeSlots ?? [...defaultFallbacksConfig.timeSlots ?? []],
|
|
633
|
-
tz: tz ?? defaultFallbacksConfig.tz
|
|
664
|
+
tz: tz ?? defaultFallbacksConfig.tz,
|
|
665
|
+
recovery: recovery ?? defaultFallbacksConfig.recovery
|
|
634
666
|
};
|
|
635
667
|
}
|
|
636
668
|
/** The raw selector string a selection serializes to ('' when empty). */
|
|
@@ -1317,9 +1349,9 @@ window.__ModuleLoader__.load({
|
|
|
1317
1349
|
"timeSlots.preset.windowLabel": "时段(只读)",
|
|
1318
1350
|
"timeSlots.preset.chainsOnly": "预设窗口已锁定:仅可编辑模型链",
|
|
1319
1351
|
"timeSlots.preset.liang-peak.label": "梁文峰",
|
|
1320
|
-
"timeSlots.preset.liang-peak.window": "09:00–12:00 与 14:00–18:00
|
|
1352
|
+
"timeSlots.preset.liang-peak.window": "09:00–12:00 与 14:00–18:00(周一至周五,UTC+8)",
|
|
1321
1353
|
"timeSlots.preset.liang-valley.label": "梁文谷",
|
|
1322
|
-
"timeSlots.preset.liang-valley.window": "Liang Peak
|
|
1354
|
+
"timeSlots.preset.liang-valley.window": "Liang Peak 之外的所有时间(UTC+8)",
|
|
1323
1355
|
"timeSlots.preset.glm-peak.label": "GLM峰",
|
|
1324
1356
|
"timeSlots.preset.glm-peak.window": "周一至周五 14:00–18:00(UTC+8)",
|
|
1325
1357
|
"timeSlots.preset.glm-valley.label": "GLM谷",
|
|
@@ -1502,9 +1534,9 @@ window.__ModuleLoader__.load({
|
|
|
1502
1534
|
"timeSlots.preset.windowLabel": "Window (read-only)",
|
|
1503
1535
|
"timeSlots.preset.chainsOnly": "Preset windows are frozen: only the model chain is editable",
|
|
1504
1536
|
"timeSlots.preset.liang-peak.label": "Liang Peak",
|
|
1505
|
-
"timeSlots.preset.liang-peak.window": "09:00–12:00 & 14:00–18:00 (
|
|
1537
|
+
"timeSlots.preset.liang-peak.window": "09:00–12:00 & 14:00–18:00 (Monday–Friday, UTC+8)",
|
|
1506
1538
|
"timeSlots.preset.liang-valley.label": "Liang Valley",
|
|
1507
|
-
"timeSlots.preset.liang-valley.window": "All times outside Liang Peak (
|
|
1539
|
+
"timeSlots.preset.liang-valley.window": "All times outside Liang Peak (UTC+8)",
|
|
1508
1540
|
"timeSlots.preset.glm-peak.label": "GLM Peak",
|
|
1509
1541
|
"timeSlots.preset.glm-peak.window": "Monday–Friday 14:00–18:00 (UTC+8)",
|
|
1510
1542
|
"timeSlots.preset.glm-valley.label": "GLM Valley",
|
package/dist/commands.d.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface FallbacksCooldownEntry {
|
|
|
57
57
|
readonly key: string;
|
|
58
58
|
/** Expiry epoch ms; `Infinity` for `revertPolicy: 'never'`. */
|
|
59
59
|
readonly untilEpochMs: number;
|
|
60
|
+
/** True for a half-open marker row (an expired cooldown awaiting a recovery probe). */
|
|
61
|
+
readonly halfOpen?: boolean;
|
|
60
62
|
}
|
|
61
63
|
/** The read-only diagnostic snapshot the `/fallbacks` handler renders. */
|
|
62
64
|
export interface FallbacksCommandSnapshot {
|
|
@@ -192,6 +194,7 @@ export declare const FALLBACKS_COMMAND_LOCALES: {
|
|
|
192
194
|
readonly cooldownNone: "无活跃冷却";
|
|
193
195
|
readonly cooldownLine: "{key} 冷却至 {time}";
|
|
194
196
|
readonly cooldownNever: "{key} 会话内不再回主";
|
|
197
|
+
readonly cooldownHalfOpen: "{key} half-open(等待恢复探针)";
|
|
195
198
|
readonly reason: {
|
|
196
199
|
readonly 'trigger-code': "触发码";
|
|
197
200
|
readonly 'always-cap': "always 上限";
|
|
@@ -246,6 +249,7 @@ export declare const FALLBACKS_COMMAND_LOCALES: {
|
|
|
246
249
|
readonly cooldownNone: "none active";
|
|
247
250
|
readonly cooldownLine: "{key} suppressed until {time}";
|
|
248
251
|
readonly cooldownNever: "{key} not reverting this session";
|
|
252
|
+
readonly cooldownHalfOpen: "{key} half-open (awaiting recovery probe)";
|
|
249
253
|
readonly reason: {
|
|
250
254
|
readonly 'trigger-code': "trigger-code";
|
|
251
255
|
readonly 'always-cap': "always-cap";
|
package/dist/config.d.ts
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
* @module dsh-llm-fallbacks/config
|
|
29
29
|
*/
|
|
30
30
|
import type { SlotRowConfig } from './time-slots.ts';
|
|
31
|
+
/** How an expired cooldown recovers the route (plan fallbacks-half-open-recovery Task 1). */
|
|
32
|
+
export type RecoveryPolicy = 'timer' | 'half-open';
|
|
31
33
|
/** How a cooled-down model comes back (spec §4). */
|
|
32
34
|
export type RevertPolicy = 'cooldown-expiry' | 'never';
|
|
33
35
|
/** A single role rule: match on provider/model patterns (spec §3). */
|
|
@@ -125,6 +127,18 @@ export interface FallbacksConfig {
|
|
|
125
127
|
* UTC+8). Not per-slot.
|
|
126
128
|
*/
|
|
127
129
|
tz?: string;
|
|
130
|
+
/**
|
|
131
|
+
* Cooldown-expiry recovery mode (plan fallbacks-half-open-recovery Task
|
|
132
|
+
* 1): `'timer'` restores the preferred candidate when the cooldown
|
|
133
|
+
* expires (today's behavior); `'half-open'` leaves the route half-open
|
|
134
|
+
* for one logged probe instead. Optional on purpose, mirroring
|
|
135
|
+
* `presets` — a required field would break library consumers that
|
|
136
|
+
* construct `FallbacksConfig` literals with the existing keys
|
|
137
|
+
* (additive, non-breaking). The value domain is guarded by the schema
|
|
138
|
+
* (`Config` in `src/schema.ts`), NOT by `validateFallbacksConfig`, and
|
|
139
|
+
* every resolved config carries a value via the schema default.
|
|
140
|
+
*/
|
|
141
|
+
recovery?: RecoveryPolicy;
|
|
128
142
|
}
|
|
129
143
|
/**
|
|
130
144
|
* Spec §4 defaults — `Config({})` must equal this (no-op install).
|
package/dist/cooldown.d.ts
CHANGED
|
@@ -24,6 +24,15 @@ export declare class CooldownStore {
|
|
|
24
24
|
suppress(key: string, untilEpochMs: number): void;
|
|
25
25
|
/** True while `key` is suppressed at time `now` (default `Date.now()`); expired entries are removed. */
|
|
26
26
|
isSuppressed(key: string, now?: number): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Raw expiry read — no lazy drop (plan fallbacks-half-open-recovery P2
|
|
29
|
+
* rule 3). `undefined` for unknown keys; `Infinity` for
|
|
30
|
+
* `revertPolicy: 'never'` entries. The half-open read path uses this to
|
|
31
|
+
* observe the lapsed `until` before the decision-path drop.
|
|
32
|
+
*/
|
|
33
|
+
peek(key: string): number | undefined;
|
|
34
|
+
/** Unfiltered map keys, in insertion order (plan P4 display sync). */
|
|
35
|
+
keys(): IterableIterator<string>;
|
|
27
36
|
/**
|
|
28
37
|
* Read-only diagnostic accessor: the active (non-expired) entries, in
|
|
29
38
|
* insertion order. This is a pure read — expired entries are FILTERED OUT
|
package/dist/index.d.ts
CHANGED
|
@@ -85,7 +85,7 @@ export type { FallbackSwitchReason, FallbacksSwitchEventData } from './events.ts
|
|
|
85
85
|
export type { AgentFallbackState, FallbackStateStore, PendingSwitch, StepFailures } from './state.ts';
|
|
86
86
|
export { resolveRole, type AgentLike, type Origin, } from './roles.ts';
|
|
87
87
|
export { annotateCandidates, createCandidateFilter, hasWildcardEntry, resolveCandidate, resolveChain, resolveChainViews, selectCandidates, type AnnotatedCandidate, type CandidateFilterOptions, type CandidateSkipReason, type FailingModel, } from './chains.ts';
|
|
88
|
-
export { defaultFallbacksConfig, detectLegacyKeys, INHERIT_ROLE_ID, ROLE_ID_PATTERN, validateFallbacksConfig, type FallbacksConfig, type FallbacksConfigLogger, type FallbacksRole, type FallbacksRoles, type FallbacksRoleRule, type FallbackStrategy, type RevertPolicy, } from './config.ts';
|
|
88
|
+
export { defaultFallbacksConfig, detectLegacyKeys, INHERIT_ROLE_ID, ROLE_ID_PATTERN, validateFallbacksConfig, type FallbacksConfig, type FallbacksConfigLogger, type FallbacksRole, type FallbacksRoles, type FallbacksRoleRule, type FallbackStrategy, type RevertPolicy, type RecoveryPolicy, } from './config.ts';
|
|
89
89
|
export { parseSelector, SelectorError, type Selector, } from './selectors.ts';
|
|
90
90
|
export { FallbacksSeedManager, type EffectiveRole, type EffectiveRolesReadback, type SeedConflict, type SeedDeclaration, type SeedDeclareOutcome, type SeedRevertFailReason, type SeedRevertOutcome, type SeedSkipReason, type SeedsIo, type SeedsWireStatus, } from './seeds.ts';
|
|
91
91
|
export { presetRoles } from './presets.ts';
|
package/dist/index.js
CHANGED
|
@@ -39,10 +39,24 @@ const PRESETS = {
|
|
|
39
39
|
"liang-peak": {
|
|
40
40
|
windows: [{
|
|
41
41
|
start: "09:00",
|
|
42
|
-
end: "12:00"
|
|
42
|
+
end: "12:00",
|
|
43
|
+
days: [
|
|
44
|
+
1,
|
|
45
|
+
2,
|
|
46
|
+
3,
|
|
47
|
+
4,
|
|
48
|
+
5
|
|
49
|
+
]
|
|
43
50
|
}, {
|
|
44
51
|
start: "14:00",
|
|
45
|
-
end: "18:00"
|
|
52
|
+
end: "18:00",
|
|
53
|
+
days: [
|
|
54
|
+
1,
|
|
55
|
+
2,
|
|
56
|
+
3,
|
|
57
|
+
4,
|
|
58
|
+
5
|
|
59
|
+
]
|
|
46
60
|
}],
|
|
47
61
|
complement: false,
|
|
48
62
|
label: "Liang Peak"
|
|
@@ -50,10 +64,24 @@ const PRESETS = {
|
|
|
50
64
|
"liang-valley": {
|
|
51
65
|
windows: [{
|
|
52
66
|
start: "09:00",
|
|
53
|
-
end: "12:00"
|
|
67
|
+
end: "12:00",
|
|
68
|
+
days: [
|
|
69
|
+
1,
|
|
70
|
+
2,
|
|
71
|
+
3,
|
|
72
|
+
4,
|
|
73
|
+
5
|
|
74
|
+
]
|
|
54
75
|
}, {
|
|
55
76
|
start: "14:00",
|
|
56
|
-
end: "18:00"
|
|
77
|
+
end: "18:00",
|
|
78
|
+
days: [
|
|
79
|
+
1,
|
|
80
|
+
2,
|
|
81
|
+
3,
|
|
82
|
+
4,
|
|
83
|
+
5
|
|
84
|
+
]
|
|
57
85
|
}],
|
|
58
86
|
complement: true,
|
|
59
87
|
label: "Liang Valley"
|
|
@@ -231,6 +259,65 @@ function resolveSlotState(config, now, tz) {
|
|
|
231
259
|
label: "all-day"
|
|
232
260
|
};
|
|
233
261
|
}
|
|
262
|
+
const ESCALATION_CAP_MS = 36e5;
|
|
263
|
+
function escalatedCooldownMs(ms, n) {
|
|
264
|
+
return Math.max(ms, Math.min(ESCALATION_CAP_MS, ms * 2 ** (n - 1)));
|
|
265
|
+
}
|
|
266
|
+
var RecoveryStore = class {
|
|
267
|
+
entries = new Map();
|
|
268
|
+
recordFailure(key) {
|
|
269
|
+
const entry = this.entries.get(key);
|
|
270
|
+
if (entry === void 0) {
|
|
271
|
+
this.entries.set(key, {
|
|
272
|
+
consecutiveFailures: 1,
|
|
273
|
+
halfOpen: false,
|
|
274
|
+
probeLogged: false
|
|
275
|
+
});
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
entry.consecutiveFailures += 1;
|
|
279
|
+
entry.halfOpen = false;
|
|
280
|
+
entry.probeLogged = false;
|
|
281
|
+
return entry.consecutiveFailures;
|
|
282
|
+
}
|
|
283
|
+
markHalfOpen(key, until) {
|
|
284
|
+
const entry = this.entries.get(key);
|
|
285
|
+
if (entry === void 0) {
|
|
286
|
+
this.entries.set(key, {
|
|
287
|
+
consecutiveFailures: 0,
|
|
288
|
+
halfOpen: true,
|
|
289
|
+
probeLogged: false,
|
|
290
|
+
expiredUntil: until
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
entry.halfOpen = true;
|
|
295
|
+
entry.probeLogged = false;
|
|
296
|
+
entry.expiredUntil = until;
|
|
297
|
+
}
|
|
298
|
+
tryMarkProbeLogged(key) {
|
|
299
|
+
const entry = this.entries.get(key);
|
|
300
|
+
if (entry === void 0 || !entry.halfOpen || entry.probeLogged) return false;
|
|
301
|
+
entry.probeLogged = true;
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
isHalfOpen(key) {
|
|
305
|
+
return this.entries.get(key)?.halfOpen === true;
|
|
306
|
+
}
|
|
307
|
+
close(key) {
|
|
308
|
+
const entry = this.entries.get(key);
|
|
309
|
+
if (entry === void 0 || !entry.halfOpen) return;
|
|
310
|
+
this.entries.delete(key);
|
|
311
|
+
}
|
|
312
|
+
halfOpenEntries() {
|
|
313
|
+
const out = [];
|
|
314
|
+
for (const [key, entry] of this.entries) if (entry.halfOpen && entry.expiredUntil !== void 0) out.push({
|
|
315
|
+
key,
|
|
316
|
+
untilEpochMs: entry.expiredUntil
|
|
317
|
+
});
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
234
321
|
//#endregion
|
|
235
322
|
//#region .build/host/config.js
|
|
236
323
|
const defaultFallbacksConfig = {
|
|
@@ -252,7 +339,8 @@ const defaultFallbacksConfig = {
|
|
|
252
339
|
presets: "bundled",
|
|
253
340
|
roleAutoMatch: true,
|
|
254
341
|
timeSlots: [],
|
|
255
|
-
tz: "Asia/Shanghai"
|
|
342
|
+
tz: "Asia/Shanghai",
|
|
343
|
+
recovery: "timer"
|
|
256
344
|
};
|
|
257
345
|
const INHERIT_ROLE_ID = "inherit";
|
|
258
346
|
const ROLE_ID_PATTERN = /^[a-z0-9-]{1,32}$/;
|
|
@@ -297,6 +385,7 @@ function validateFallbacksConfig(config, logger) {
|
|
|
297
385
|
if (row.start !== void 0 || row.end !== void 0 || row.days !== void 0 && row.days.length > 0) logger.warn(`llm-fallbacks: preset "${preset}" carries its own window/day fields — preset windows are fixed code constants; the row is ignored`);
|
|
298
386
|
}
|
|
299
387
|
}
|
|
388
|
+
if ((config.recovery ?? "timer") === "half-open" && config.cooldownMs >= 36e5) logger.warn(`llm-fallbacks: recovery "half-open" escalation is inert — cooldownMs (${config.cooldownMs} ms) is at or above the 1-hour escalation cap (${ESCALATION_CAP_MS} ms), so every suppression stays flat at cooldownMs`);
|
|
300
389
|
}
|
|
301
390
|
function detectLegacyKeys(source) {
|
|
302
391
|
const keys = [];
|
|
@@ -366,7 +455,8 @@ const Config = z.object({
|
|
|
366
455
|
days: z.array(z.number()),
|
|
367
456
|
chain: z.array(z.string())
|
|
368
457
|
})).default([]),
|
|
369
|
-
tz: z.string().default("Asia/Shanghai")
|
|
458
|
+
tz: z.string().default("Asia/Shanghai"),
|
|
459
|
+
recovery: z.union([z.const("timer"), z.const("half-open")]).default("timer")
|
|
370
460
|
});
|
|
371
461
|
function buildAutomatchPrompt(roles, agent) {
|
|
372
462
|
const lines = [
|
|
@@ -623,6 +713,12 @@ var CooldownStore = class {
|
|
|
623
713
|
}
|
|
624
714
|
return true;
|
|
625
715
|
}
|
|
716
|
+
peek(key) {
|
|
717
|
+
return this.entries.get(key);
|
|
718
|
+
}
|
|
719
|
+
keys() {
|
|
720
|
+
return this.entries.keys();
|
|
721
|
+
}
|
|
626
722
|
snapshot(now = Date.now()) {
|
|
627
723
|
const active = [];
|
|
628
724
|
for (const [key, until] of this.entries) {
|
|
@@ -673,7 +769,8 @@ var FallbackStateStore = class {
|
|
|
673
769
|
failed: new StepFailureSet(),
|
|
674
770
|
switchCount: 0
|
|
675
771
|
},
|
|
676
|
-
cooldown: new CooldownStore()
|
|
772
|
+
cooldown: new CooldownStore(),
|
|
773
|
+
recovery: new RecoveryStore()
|
|
677
774
|
};
|
|
678
775
|
this.states.set(agentId, state);
|
|
679
776
|
}
|
|
@@ -724,8 +821,21 @@ var FallbackStateStore = class {
|
|
|
724
821
|
suppress(state, key, untilEpochMs) {
|
|
725
822
|
state.cooldown.suppress(key, untilEpochMs);
|
|
726
823
|
}
|
|
727
|
-
isSuppressed(state, key, now = Date.now()) {
|
|
728
|
-
return state.cooldown.isSuppressed(key, now);
|
|
824
|
+
isSuppressed(state, key, now = Date.now(), recovery = "timer") {
|
|
825
|
+
if (recovery !== "half-open") return state.cooldown.isSuppressed(key, now);
|
|
826
|
+
const until = state.cooldown.peek(key);
|
|
827
|
+
if (until === void 0) return false;
|
|
828
|
+
if (until > now) return true;
|
|
829
|
+
state.cooldown.isSuppressed(key, now);
|
|
830
|
+
state.recovery.markHalfOpen(key, until);
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
syncRecovery(state, now, mode) {
|
|
834
|
+
if (mode !== "half-open") return;
|
|
835
|
+
for (const key of state.cooldown.keys()) this.isSuppressed(state, key, now, mode);
|
|
836
|
+
}
|
|
837
|
+
observeSuccess(state, key) {
|
|
838
|
+
state.recovery.close(key);
|
|
729
839
|
}
|
|
730
840
|
};
|
|
731
841
|
//#endregion
|
|
@@ -743,7 +853,8 @@ const CONFIG_KEYS = {
|
|
|
743
853
|
presets: true,
|
|
744
854
|
roleAutoMatch: true,
|
|
745
855
|
timeSlots: true,
|
|
746
|
-
tz: true
|
|
856
|
+
tz: true,
|
|
857
|
+
recovery: true
|
|
747
858
|
};
|
|
748
859
|
const ROLES_KEYS = {
|
|
749
860
|
list: true,
|
|
@@ -977,6 +1088,7 @@ const FALLBACKS_COMMAND_LOCALES = {
|
|
|
977
1088
|
cooldownNone: "无活跃冷却",
|
|
978
1089
|
cooldownLine: "{key} 冷却至 {time}",
|
|
979
1090
|
cooldownNever: "{key} 会话内不再回主",
|
|
1091
|
+
cooldownHalfOpen: "{key} half-open(等待恢复探针)",
|
|
980
1092
|
reason: {
|
|
981
1093
|
"trigger-code": "触发码",
|
|
982
1094
|
"always-cap": "always 上限"
|
|
@@ -1031,6 +1143,7 @@ const FALLBACKS_COMMAND_LOCALES = {
|
|
|
1031
1143
|
cooldownNone: "none active",
|
|
1032
1144
|
cooldownLine: "{key} suppressed until {time}",
|
|
1033
1145
|
cooldownNever: "{key} not reverting this session",
|
|
1146
|
+
cooldownHalfOpen: "{key} half-open (awaiting recovery probe)",
|
|
1034
1147
|
reason: {
|
|
1035
1148
|
"trigger-code": "trigger-code",
|
|
1036
1149
|
"always-cap": "always-cap"
|
|
@@ -1123,6 +1236,7 @@ function formatSwitch(entry, t) {
|
|
|
1123
1236
|
return t.switchLine.replace("{from}", from).replace("{to}", to).replace("{role}", entry.role).replace("{reason}", t.reason[entry.reason] ?? entry.reason);
|
|
1124
1237
|
}
|
|
1125
1238
|
function formatCooldown(entry, t) {
|
|
1239
|
+
if (entry.halfOpen === true) return t.cooldownHalfOpen.replace("{key}", entry.key);
|
|
1126
1240
|
if (!Number.isFinite(entry.untilEpochMs)) return t.cooldownNever.replace("{key}", entry.key);
|
|
1127
1241
|
return t.cooldownLine.replace("{key}", entry.key).replace("{time}", new Date(entry.untilEpochMs).toISOString());
|
|
1128
1242
|
}
|
|
@@ -2120,7 +2234,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
2120
2234
|
const { all, wildcard } = resolveChainViews(config.roles.list, rootTail, role, current.provider, current.model, logger.warn);
|
|
2121
2235
|
if (all.length === 0) return null;
|
|
2122
2236
|
const modelExists = hasWildcardEntry(config.roles.list, rootTail, role) ? await makeModelExists(ctx, [...new Set(all.map((candidate) => candidate.provider))]) : void 0;
|
|
2123
|
-
const cooldown = { isSuppressed: (key) => state !== void 0 && states.isSuppressed(state, key) };
|
|
2237
|
+
const cooldown = { isSuppressed: (key) => state !== void 0 && states.isSuppressed(state, key, Date.now(), source().recovery ?? "timer") };
|
|
2124
2238
|
const failed = { has: (key) => state !== void 0 && state.stepFailures.failed.has(key) };
|
|
2125
2239
|
const surviving = selectCandidates(all, wildcard, createCandidateFilter({
|
|
2126
2240
|
current,
|
|
@@ -2129,6 +2243,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
2129
2243
|
}), modelExists);
|
|
2130
2244
|
const target = surviving[0];
|
|
2131
2245
|
if (target === void 0 || target.model === void 0) return null;
|
|
2246
|
+
if ((source().recovery ?? "timer") === "half-open" && state !== void 0 && state.recovery.tryMarkProbeLogged(selectorKey(target.provider, target.model))) logger.info("llm-fallbacks: agent \"%s\" half-open probe %s/%s (role=%s, reason=%s)", agent.id, target.provider, target.model, role, reason);
|
|
2132
2247
|
logger.info("llm-fallbacks: agent \"%s\" fallback switch (降级切换) %s/%s -> %s/%s (role=%s, reason=%s, candidates=%o)", agent.id, current.provider, current.model, target.provider, target.model, role, reason, annotateCandidates(all, surviving, {
|
|
2133
2248
|
current,
|
|
2134
2249
|
cooldown,
|
|
@@ -2150,13 +2265,22 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
2150
2265
|
function commit(state, pending, turn, step) {
|
|
2151
2266
|
const config = source();
|
|
2152
2267
|
const fromKey = selectorKey(pending.from.provider, pending.from.model);
|
|
2153
|
-
const until = config.revertPolicy === "never" ? Number.POSITIVE_INFINITY : Date.now() + config.cooldownMs;
|
|
2268
|
+
const until = config.revertPolicy === "never" ? Number.POSITIVE_INFINITY : config.recovery === "half-open" ? Date.now() + escalatedCooldownMs(config.cooldownMs, state.recovery.recordFailure(fromKey)) : Date.now() + config.cooldownMs;
|
|
2154
2269
|
states.syncStep(state, turn, step);
|
|
2155
2270
|
states.writePending(state, pending);
|
|
2156
2271
|
states.suppress(state, fromKey, until);
|
|
2157
2272
|
states.recordFailure(state, fromKey);
|
|
2158
2273
|
states.recordSwitch(state);
|
|
2159
2274
|
}
|
|
2275
|
+
function failHalfOpenProbe(agentId, current) {
|
|
2276
|
+
const key = selectorKey(current.provider, current.model);
|
|
2277
|
+
if ((source().recovery ?? "timer") !== "half-open") return;
|
|
2278
|
+
if (source().revertPolicy === "never") return;
|
|
2279
|
+
if (states.peek(agentId)?.recovery.isHalfOpen(key) !== true) return;
|
|
2280
|
+
const state = states.get(agentId);
|
|
2281
|
+
const n = state.recovery.recordFailure(key);
|
|
2282
|
+
states.suppress(state, key, Date.now() + escalatedCooldownMs(source().cooldownMs, n));
|
|
2283
|
+
}
|
|
2160
2284
|
ctx.on("agent/request-error", async ({ agent, turn, step, provider, failure }, next) => {
|
|
2161
2285
|
const config = source();
|
|
2162
2286
|
if (!config.enabled || !config.triggerCodes.includes(failure.code)) return next();
|
|
@@ -2164,7 +2288,10 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
2164
2288
|
if (!current.model) return next();
|
|
2165
2289
|
try {
|
|
2166
2290
|
const pending = await decide(agent, turn, step, current, "trigger-code", states.peek(agent.id));
|
|
2167
|
-
if (pending === null)
|
|
2291
|
+
if (pending === null) {
|
|
2292
|
+
failHalfOpenProbe(agent.id, current);
|
|
2293
|
+
return next();
|
|
2294
|
+
}
|
|
2168
2295
|
commit(states.get(agent.id), pending, turn, step);
|
|
2169
2296
|
return { kind: "retry" };
|
|
2170
2297
|
} catch (error) {
|
|
@@ -2234,7 +2361,10 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
2234
2361
|
commit(commitState, pending, turn, step);
|
|
2235
2362
|
const appliedCap = states.applyPending(commitState, turn, step);
|
|
2236
2363
|
if (appliedCap !== void 0) return overrideConfig(seed, appliedCap.to);
|
|
2237
|
-
}
|
|
2364
|
+
} else failHalfOpenProbe(agent.id, {
|
|
2365
|
+
provider: seed.provider,
|
|
2366
|
+
model: seed.model
|
|
2367
|
+
});
|
|
2238
2368
|
}
|
|
2239
2369
|
return seed;
|
|
2240
2370
|
});
|
|
@@ -2248,6 +2378,16 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
2248
2378
|
dispatchInjected.delete(agent.id);
|
|
2249
2379
|
slotWinners.delete(agent.id);
|
|
2250
2380
|
});
|
|
2381
|
+
ctx.on("session/event", (session, event) => {
|
|
2382
|
+
if (event.type !== "assistant/message") return;
|
|
2383
|
+
if ((source().recovery ?? "timer") !== "half-open") return;
|
|
2384
|
+
if (event.data.interrupted === true) return;
|
|
2385
|
+
const message = event.data.message;
|
|
2386
|
+
if (message.source.kind !== "model") return;
|
|
2387
|
+
const state = states.peek(session.id);
|
|
2388
|
+
if (state === void 0) return;
|
|
2389
|
+
states.observeSuccess(state, selectorKey(message.source.provider, message.source.model));
|
|
2390
|
+
});
|
|
2251
2391
|
ctx.effect(() => () => {
|
|
2252
2392
|
states.clear();
|
|
2253
2393
|
dispatchInjected.clear();
|
|
@@ -2258,13 +2398,19 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
2258
2398
|
const config = source();
|
|
2259
2399
|
const role = resolveRole(agent, config.roles.rules, roleIds, logger.warn);
|
|
2260
2400
|
const state = states.peek(agent.id);
|
|
2401
|
+
const recovery = config.recovery ?? "timer";
|
|
2402
|
+
if (state !== void 0 && recovery === "half-open") states.syncRecovery(state, Date.now(), recovery);
|
|
2261
2403
|
return {
|
|
2262
2404
|
origin: agent.session.header?.origin ?? "root",
|
|
2263
2405
|
role,
|
|
2264
2406
|
...resolveChainForDiagnostic(config.roles.list, config.rootChain, role, logger.warn),
|
|
2265
2407
|
slot: resolveSlotState(config, new Date(), config.tz ?? "Asia/Shanghai"),
|
|
2266
2408
|
switches: recentFallbacksSwitches(agent.session.events, 5),
|
|
2267
|
-
cooldown: state === void 0 ? [] : state.cooldown.snapshot()
|
|
2409
|
+
cooldown: state === void 0 ? [] : recovery === "half-open" ? [...state.cooldown.snapshot(), ...state.recovery.halfOpenEntries().map((entry) => ({
|
|
2410
|
+
key: entry.key,
|
|
2411
|
+
untilEpochMs: entry.untilEpochMs,
|
|
2412
|
+
halfOpen: true
|
|
2413
|
+
}))] : state.cooldown.snapshot()
|
|
2268
2414
|
};
|
|
2269
2415
|
},
|
|
2270
2416
|
getConfig() {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Half-open recovery state machine (plan fallbacks-half-open-recovery Task 2).
|
|
3
|
+
*
|
|
4
|
+
* `RecoveryStore` tracks per-route consecutive-failure counters and half-open
|
|
5
|
+
* episode flags, keyed `${provider}/${model}` via `selectorKey`. It is held
|
|
6
|
+
* per-agent on `AgentFallbackState` and is session-scoped in-memory (same
|
|
7
|
+
* lifetime as the cooldown store; a restart resets every route to a flat
|
|
8
|
+
* first cooldown).
|
|
9
|
+
*
|
|
10
|
+
* The store is pure logic — zero imports, the cooldown.ts purity precedent —
|
|
11
|
+
* so it is unit-testable without a host (recovery.spec.ts imports this module
|
|
12
|
+
* directly).
|
|
13
|
+
*
|
|
14
|
+
* State machine (plan P2):
|
|
15
|
+
* 1. `recordFailure` — a suppression write: increments the consecutive-failure
|
|
16
|
+
* counter and clears any half-open episode (rule 1).
|
|
17
|
+
* 2. `escalatedCooldownMs` — bounded geometric escalation of the suppression
|
|
18
|
+
* duration (rule 2).
|
|
19
|
+
* 3. `markHalfOpen` — an expired cooldown leaves the route half-open for one
|
|
20
|
+
* logged probe (rule 3).
|
|
21
|
+
* 4. `tryMarkProbeLogged` — the once-per-episode admission marker, not a gate
|
|
22
|
+
* (rule 4).
|
|
23
|
+
* 5. `isHalfOpen` — probe-failure gate for the no-surviving-target path
|
|
24
|
+
* (rule 5b).
|
|
25
|
+
* 6. `close` — an observed completion closes the circuit, only from half-open
|
|
26
|
+
* (rule 6).
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-llm-fallbacks/recovery
|
|
29
|
+
*/
|
|
30
|
+
/** Escalation multiplier applied per consecutive failure (rule 2). */
|
|
31
|
+
export declare const ESCALATION_MULTIPLIER = 2;
|
|
32
|
+
/** Escalation ceiling: 1 hour (rule 2). */
|
|
33
|
+
export declare const ESCALATION_CAP_MS = 3600000;
|
|
34
|
+
/**
|
|
35
|
+
* Bounded geometric escalation of the suppression duration (rule 2):
|
|
36
|
+
* `max(ms, min(ESCALATION_CAP_MS, ms * ESCALATION_MULTIPLIER ** (n - 1)))`
|
|
37
|
+
* for `n >= 1`. `n = 1` is flat `ms`; the `max(ms, ...)` floor keeps
|
|
38
|
+
* escalation from ever shortening below the configured flat cooldown when
|
|
39
|
+
* `ms >= ESCALATION_CAP_MS`; `ms = 0` degenerates to today's flat-0
|
|
40
|
+
* semantics.
|
|
41
|
+
*/
|
|
42
|
+
export declare function escalatedCooldownMs(ms: number, n: number): number;
|
|
43
|
+
/**
|
|
44
|
+
* Per-route half-open recovery state (plan P2). Pure logic — no host
|
|
45
|
+
* dependencies, no `@deepseek-ai/*` imports.
|
|
46
|
+
*/
|
|
47
|
+
export declare class RecoveryStore {
|
|
48
|
+
private entries;
|
|
49
|
+
/**
|
|
50
|
+
* Record a suppression write for `key` (rule 1): increments the
|
|
51
|
+
* consecutive-failure counter and clears any in-progress half-open episode
|
|
52
|
+
* (a failure while half-open re-suppresses escalated — rule 5). Returns the
|
|
53
|
+
* new counter value `n`; the caller computes the escalated duration with
|
|
54
|
+
* {@link escalatedCooldownMs}.
|
|
55
|
+
*/
|
|
56
|
+
recordFailure(key: string): number;
|
|
57
|
+
/**
|
|
58
|
+
* Open a half-open episode for `key` (rule 3): the lapsed cooldown leaves
|
|
59
|
+
* the route half-open for one logged probe. Resets the per-episode probe
|
|
60
|
+
* marker; the consecutive-failure counter survives the episode so a probe
|
|
61
|
+
* failure escalates to `n + 1` (rule 5). Creates the entry on first sight
|
|
62
|
+
* (mid-session mode flips can surface a cooldown entry with no recovery
|
|
63
|
+
* entry — rule 8).
|
|
64
|
+
*/
|
|
65
|
+
markHalfOpen(key: string, until: number): void;
|
|
66
|
+
/**
|
|
67
|
+
* Mark the episode's one logged admission (rule 4). Returns `true` only for
|
|
68
|
+
* the first admission of a half-open episode; later admissions while the
|
|
69
|
+
* episode is unresolved return `false` and route normally (the marker is
|
|
70
|
+
* not a gate — there is no admission limit).
|
|
71
|
+
*/
|
|
72
|
+
tryMarkProbeLogged(key: string): boolean;
|
|
73
|
+
/** True while `key` has an in-progress half-open episode (rule 5b gate). */
|
|
74
|
+
isHalfOpen(key: string): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Close the circuit for `key` (rule 6): acts only when the entry exists and
|
|
77
|
+
* is half-open — the entry is deleted (counter reset by deletion, preference
|
|
78
|
+
* fully restored). A completion observed while the route is actively
|
|
79
|
+
* suppressed, or with no entry, is a no-op: a stale in-flight success must
|
|
80
|
+
* not cancel a fresher escalated re-suppression.
|
|
81
|
+
*/
|
|
82
|
+
close(key: string): void;
|
|
83
|
+
/**
|
|
84
|
+
* The in-progress half-open episodes, in insertion order (display path —
|
|
85
|
+
* plan P4). `untilEpochMs` is the lapsed expiry epoch that drives the
|
|
86
|
+
* `/fallbacks` marker row.
|
|
87
|
+
*/
|
|
88
|
+
halfOpenEntries(): Array<{
|
|
89
|
+
key: string;
|
|
90
|
+
untilEpochMs: number;
|
|
91
|
+
}>;
|
|
92
|
+
}
|
package/dist/state.d.ts
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import type { FallbackSwitchReason } from './events.ts';
|
|
31
31
|
import { CooldownStore, StepFailureSet } from './cooldown.ts';
|
|
32
|
+
import { RecoveryStore } from './recovery.ts';
|
|
33
|
+
import type { RecoveryPolicy } from './config.ts';
|
|
32
34
|
/** Decision awaiting application at the next `agent/request` boundary (spec §5.1). */
|
|
33
35
|
export interface PendingSwitch {
|
|
34
36
|
from: {
|
|
@@ -64,6 +66,8 @@ export interface AgentFallbackState {
|
|
|
64
66
|
stepFailures: StepFailures;
|
|
65
67
|
/** `provider/model → expiry epoch ms`; lazily expired on read. */
|
|
66
68
|
cooldown: CooldownStore;
|
|
69
|
+
/** Half-open recovery bookkeeping (plan fallbacks-half-open-recovery P2). */
|
|
70
|
+
recovery: RecoveryStore;
|
|
67
71
|
}
|
|
68
72
|
/**
|
|
69
73
|
* The `Map<agent.id, AgentFallbackState>` store plus the state-machine
|
|
@@ -107,6 +111,27 @@ export declare class FallbackStateStore {
|
|
|
107
111
|
clearStepState(state: AgentFallbackState): void;
|
|
108
112
|
/** Suppress `key` until `untilEpochMs` (`Infinity` for `revertPolicy: 'never'`). */
|
|
109
113
|
suppress(state: AgentFallbackState, key: string, untilEpochMs: number): void;
|
|
110
|
-
/**
|
|
111
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Lazy cooldown read (spec §5.1; plan fallbacks-half-open-recovery P2
|
|
116
|
+
* rule 3). Under `'timer'` (the default) today's body runs verbatim:
|
|
117
|
+
* expired entries are dropped on read. Under `'half-open'` the raw `until`
|
|
118
|
+
* is read via `CooldownStore.peek` (no lazy drop): absent ⇒ false;
|
|
119
|
+
* `until > now` ⇒ true; `until <= now` ⇒ the entry is dropped via the
|
|
120
|
+
* existing `isSuppressed` and the route is marked half-open, returning
|
|
121
|
+
* false. `Infinity` (`revertPolicy: 'never'`) never satisfies `until <=
|
|
122
|
+
* now` ⇒ the never short-circuit is structural on the read side.
|
|
123
|
+
*/
|
|
124
|
+
isSuppressed(state: AgentFallbackState, key: string, now?: number, recovery?: RecoveryPolicy): boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Bulk half-open transition for the display path (plan P4): walks the
|
|
127
|
+
* cooldown keys applying rule 3 per key. A pure no-op under `'timer'` —
|
|
128
|
+
* internal state and output stay byte-identical in default mode.
|
|
129
|
+
*/
|
|
130
|
+
syncRecovery(state: AgentFallbackState, now: number, mode: RecoveryPolicy): void;
|
|
131
|
+
/**
|
|
132
|
+
* Observed completion ⇒ close the circuit (plan P2 rule 6): acts only when
|
|
133
|
+
* the entry exists and is half-open; otherwise a no-op (a stale in-flight
|
|
134
|
+
* success must not cancel a fresher escalated re-suppression).
|
|
135
|
+
*/
|
|
136
|
+
observeSuccess(state: AgentFallbackState, key: string): void;
|
|
112
137
|
}
|
package/dist/time-slots.d.ts
CHANGED
|
@@ -58,9 +58,9 @@ export interface PresetDefinition {
|
|
|
58
58
|
label: string;
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
|
-
* Frozen preset windows (UTC+8). `liang
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* Frozen preset windows (UTC+8). `liang-peak` and `glm-peak` are
|
|
62
|
+
* Monday–Friday only (day mask `[1, 2, 3, 4, 5]`); their valleys are the
|
|
63
|
+
* `complement: true` of the corresponding peak (weekends included).
|
|
64
64
|
*/
|
|
65
65
|
export declare const PRESETS: Record<PresetId, PresetDefinition>;
|
|
66
66
|
/**
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"dsh",
|
|
6
6
|
"dsh-plugin"
|
|
7
7
|
],
|
|
8
|
-
"version": "0.3.
|
|
8
|
+
"version": "0.3.5",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"main": "./dist/index.js",
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
@@ -61,23 +61,23 @@
|
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
64
|
-
"@deepseek-ai/dsh-agent": "^0.1.1-rc.
|
|
65
|
-
"@deepseek-ai/dsh-api-gateway": "^0.1.1-rc.
|
|
66
|
-
"@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.
|
|
67
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.
|
|
68
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.
|
|
69
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.
|
|
71
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.
|
|
72
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.
|
|
73
|
-
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.1-rc.
|
|
74
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.
|
|
75
|
-
"@deepseek-ai/dsh-commands": "^0.1.1-rc.
|
|
76
|
-
"@deepseek-ai/dsh-llm": "^0.1.1-rc.
|
|
77
|
-
"@deepseek-ai/dsh-session": "^0.1.1-rc.
|
|
78
|
-
"@deepseek-ai/dsh-settings": "^0.1.1-rc.
|
|
79
|
-
"@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.
|
|
80
|
-
"@deepseek-ai/dsh-typert-registry": "^0.1.1-rc.
|
|
64
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
|
|
65
|
+
"@deepseek-ai/dsh-api-gateway": "^0.1.1-rc.2",
|
|
66
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.1-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
76
|
+
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
|
|
77
|
+
"@deepseek-ai/dsh-session": "^0.1.1-rc.2",
|
|
78
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
|
|
79
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2",
|
|
80
|
+
"@deepseek-ai/dsh-typert-registry": "^0.1.1-rc.2",
|
|
81
81
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
82
82
|
"react": "^18.2.0"
|
|
83
83
|
},
|