@whaletalk/dsh-codex-collab 0.1.7 → 0.1.10

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +197 -26
  3. package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
  4. package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +102 -6
  5. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
  6. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +571 -44
  7. package/docs/CODEX_DSH_APPROVAL_DELEGATION_PLAN.md +130 -0
  8. package/docs/CODEX_DSH_APPROVAL_M0_EVIDENCE.md +285 -0
  9. package/docs/CODEX_DSH_DELIVERY_CONTROL_PLAN.md +81 -0
  10. package/docs/CODEX_DSH_MODEL_ROUTING_PLAN.md +75 -0
  11. package/docs/CODEX_DSH_TASK_REPLY_PLAN.md +206 -0
  12. package/docs/DSH_APPROVAL_DELEGATION.md +142 -0
  13. package/docs/DSH_DELIVERY_M0_EVIDENCE.md +69 -0
  14. package/docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md +166 -0
  15. package/docs/DSH_TO_CODEX_INBOX.md +167 -0
  16. package/docs/DSH_TO_CODEX_MESSAGING_PLAN.md +140 -0
  17. package/docs/codex-desktop-attachment-evidence.md +116 -0
  18. package/harness/audit-tail.mjs +32 -0
  19. package/harness/codex-approvals.mjs +1078 -0
  20. package/harness/codex-associations.mjs +297 -0
  21. package/harness/codex-outbox.mjs +432 -0
  22. package/harness/dsh-bridge.mjs +2429 -167
  23. package/harness/fs-lock.mjs +208 -0
  24. package/harness/model-route-host.mjs +530 -0
  25. package/harness/model-routing.mjs +500 -0
  26. package/harness/session-observer.mjs +217 -0
  27. package/harness/session-target.mjs +291 -2
  28. package/harness/task-table.mjs +34 -0
  29. package/mcp/codex-desktop-probe.mjs +407 -0
  30. package/mcp/codex-inbox.mjs +271 -0
  31. package/mcp/dsh-mcp.mjs +616 -24
  32. package/package.json +4 -2
@@ -0,0 +1,500 @@
1
+ /**
2
+ * 动态模型路由(M1):目录快照、候选选择与有界故障切换。
3
+ *
4
+ * 设计约束(来自 Root canonical 计划与 SDK 只读笔记):
5
+ * - 只用公有 API `llm.listProviders/listModels/resolveModelInfo/resolveCallConfig`;只枚举**已注册**配置;
6
+ * 目录可见 ≠ 凭据/额度/健康已验证(本模块从不把 catalog 当健康结论)。
7
+ * - provider+model 是**唯一身份(pair)**:跨 provider 同名模型绝不合并。
8
+ * - 目录失败**按 provider 隔离**;缓存有界且可失效;**永不永久缓存 null**。
9
+ * - 元数据没有质量/价格/速度评分;`toolUpdate` 是历史投影规则,**不当作"支持工具"布尔**。
10
+ * - 先满足真实模态/上下文/effort 与用户显式选择,再按 DeepSeek 偏好与可解释名称提示排序。
11
+ * - 失败分类只用**稳定 code/status**;切换有界(默认最多 3 个不同候选、单路与总时限)。
12
+ *
13
+ * 本模块**不**:调用 selectModel/saveSelection、写 readonly options、改全局 preset、发起任何网络请求。
14
+ */
15
+
16
+ /** 目录快照默认 TTL(毫秒)。拓扑变更事件会主动失效。 */
17
+ export const CATALOG_TTL_MS = 30_000
18
+ /** 目录失败(某 provider)的短冷却:**不永久缓存 null**,到期后重新尝试。 */
19
+ export const CATALOG_FAILURE_TTL_MS = 5_000
20
+ /** 单次快照最多枚举的 provider / 每 provider 模型数(有界)。 */
21
+ export const MAX_PROVIDERS = 32
22
+ export const MAX_MODELS_PER_PROVIDER = 200
23
+
24
+ export const ROUTE_MODES = ['auto', 'fast', 'pro', 'inherit', 'exact']
25
+
26
+ /** 稳定失败码分类(不读 message 文本)。 */
27
+ export const SWITCHABLE_CODES = new Set([
28
+ 'AUTH', 'MISSING_CREDENTIAL', 'ACCOUNT_QUOTA', 'QUOTA', 'UNKNOWN_MODEL', 'MODEL_NOT_FOUND', 'NO_ADAPTER',
29
+ ])
30
+ export const RETRYABLE_CODES = new Set(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE'])
31
+ /** 明确**不**靠切模型绕过:用户取消、拒答/安全、审批、工具错误、请求本身非法、未知插件 bug。 */
32
+ export const NEVER_SWITCH_CODES = new Set([
33
+ 'ABORTED', 'USER_ABORT', 'CANCELLED', 'SAFETY', 'CONTENT_FILTER', 'REFUSAL',
34
+ 'TOOL_ERROR', 'TOOL_FAILED', 'APPROVAL_DENIED', 'APPROVAL_REJECTED', 'INVALID_REQUEST', 'UNSUPPORTED_REASONING_EFFORT',
35
+ ])
36
+
37
+ const pairKey = (provider, model) => String(provider) + '\u0000' + String(model)
38
+ export const pairOf = (provider, model) => ({ provider, model, key: pairKey(provider, model) })
39
+
40
+ /** 有界等待/超时(目录发现、retry-after 都用它;绝不无限等)。 */
41
+ export const withDeadline = (promise, ms, onTimeout) => new Promise((resolve, reject) => {
42
+ const timer = setTimeout(() => {
43
+ if (typeof onTimeout === 'function') { try { onTimeout() } catch (e) { /* ignore */ } }
44
+ reject(Object.assign(new Error('deadline exceeded after ' + ms + 'ms'), { code: 'DEADLINE_EXCEEDED' }))
45
+ }, Math.max(1, ms))
46
+ Promise.resolve(promise).then(
47
+ (value) => { clearTimeout(timer); resolve(value) },
48
+ (error) => { clearTimeout(timer); reject(error) },
49
+ )
50
+ })
51
+
52
+ /** 有界延时(retry-after 用):到点或被 signal/aborted 立刻返回,绝不无限等。 */
53
+ export const boundedDelay = (ms, options = {}) => new Promise((resolve) => {
54
+ const cap = Number.isFinite(options.maxMs) ? Math.max(0, Math.min(ms, options.maxMs)) : Math.max(0, ms)
55
+ let done = false
56
+ const finish = () => { if (done) return; done = true; clearTimeout(timer); if (typeof options.onWake === 'function') options.onWake(); resolve() }
57
+ const timer = setTimeout(finish, cap)
58
+ const signal = options.signal
59
+ if (signal !== undefined && signal !== null && typeof signal.addEventListener === 'function') {
60
+ if (signal.aborted === true) { finish(); return }
61
+ signal.addEventListener('abort', finish, { once: true })
62
+ }
63
+ if (typeof options.isCancelled === 'function' && options.isCancelled() === true) finish()
64
+ })
65
+
66
+ const modelIdOf = (entry) => {
67
+ if (typeof entry === 'string') return entry
68
+ if (entry === null || typeof entry !== 'object') return ''
69
+ return typeof entry.id === 'string' && entry.id !== '' ? entry.id : (typeof entry.name === 'string' ? entry.name : '')
70
+ }
71
+
72
+ const modalitiesOf = (info) => {
73
+ // 未知 → 返回 undefined(诚实标未知,不伪造支持)
74
+ const raw = info !== null && typeof info === 'object' ? (info.inputModalities !== undefined ? info.inputModalities : (info.model !== undefined ? info.model.inputModalities : undefined)) : undefined
75
+ if (!Array.isArray(raw)) return undefined
76
+ return raw.filter((m) => typeof m === 'string')
77
+ }
78
+
79
+ /** 单 provider 目录发现的有界超时(防止一个挂死的 adapter 拖住所有候选)。 */
80
+ export const PROVIDER_DISCOVERY_TIMEOUT_MS = 3000
81
+
82
+ /**
83
+ * 从 ResolvedModelInfo 提取可解释能力(缺字段 = 未知,绝不当作"支持")。
84
+ * 注意:官方 `reasoning.efforts` 是**对象数组** `[{id,name}, ...]`(SDK 实测),
85
+ * 也兼容字符串数组;只滤 string 会把支持的 effort 全判成空数组(第一版验收反例)。
86
+ */
87
+ export const capabilityOf = (info) => {
88
+ const out = { known: false }
89
+ if (info === null || typeof info !== 'object') return out
90
+ const context = info.context !== null && typeof info.context === 'object' ? info.context : undefined
91
+ if (context !== undefined && Number.isFinite(context.contextWindow)) { out.contextWindow = context.contextWindow; out.known = true }
92
+ if (Number.isFinite(info.defaultMaxTokens)) { out.defaultMaxTokens = info.defaultMaxTokens; out.known = true }
93
+ const reasoning = info.reasoning !== null && typeof info.reasoning === 'object' ? info.reasoning : undefined
94
+ if (reasoning !== undefined) {
95
+ if (Array.isArray(reasoning.efforts)) {
96
+ const ids = []
97
+ for (const entry of reasoning.efforts) {
98
+ if (typeof entry === 'string') { ids.push(entry); continue }
99
+ if (entry !== null && typeof entry === 'object' && typeof entry.id === 'string') ids.push(entry.id)
100
+ }
101
+ out.efforts = ids
102
+ }
103
+ const defaultEffort = reasoning.defaultEffort
104
+ if (typeof defaultEffort === 'string') out.defaultEffort = defaultEffort
105
+ else if (defaultEffort !== null && typeof defaultEffort === 'object' && typeof defaultEffort.id === 'string') out.defaultEffort = defaultEffort.id
106
+ out.known = true
107
+ }
108
+ const modalities = modalitiesOf(info)
109
+ if (modalities !== undefined) { out.inputModalities = modalities; out.known = true }
110
+ // toolUpdate/systemPromptUpdate 是**历史投影规则**,只作为附加事实保留,绝不当"支持工具"布尔。
111
+ const toolUpdate = info.toolUpdate !== undefined ? info.toolUpdate : (info.model !== undefined ? info.model.toolUpdate : undefined)
112
+ if (typeof toolUpdate === 'string' || (toolUpdate !== null && typeof toolUpdate === 'object')) out.toolUpdateRule = toolUpdate
113
+ return out
114
+ }
115
+
116
+ /** 已证不可用的解析失败码(必须**排除**候选,不能降级成"未知能力"再送去准备期失败)。 */
117
+ export const UNRESOLVABLE_CAPABILITY_CODES = new Set(['UNKNOWN_MODEL', 'MODEL_NOT_FOUND', 'INVALID_CONFIG', 'NO_ADAPTER', 'UNSUPPORTED_REASONING_EFFORT'])
118
+
119
+ /**
120
+ * 目录快照:provider 失败隔离、有界缓存、可主动失效、失败**不永久**缓存。
121
+ * `llm` 为官方 Llm 服务(listProviders/listModels/resolveModelInfo)。
122
+ */
123
+ export const createModelCatalog = (options = {}) => {
124
+ const llm = options.llm
125
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
126
+ const ttlMs = Number.isFinite(options.ttlMs) ? options.ttlMs : CATALOG_TTL_MS
127
+ const failureTtlMs = Number.isFinite(options.failureTtlMs) ? options.failureTtlMs : CATALOG_FAILURE_TTL_MS
128
+ const providerTimeoutMs = Number.isFinite(options.providerTimeoutMs) ? options.providerTimeoutMs : PROVIDER_DISCOVERY_TIMEOUT_MS
129
+ let cache = null // { at, pairs, failures }
130
+ const providerFailures = new Map() // provider → { at, reason }
131
+ const infoCache = new Map() // pairKey → { at, info }
132
+
133
+ const invalidate = () => { cache = null; infoCache.clear() }
134
+
135
+ const snapshot = async ({ force = false } = {}) => {
136
+ const at = now()
137
+ if (!force && cache !== null && at - cache.at < ttlMs) return { ...cache, stale: false, servedFromCache: true }
138
+ if (llm === undefined || llm === null || typeof llm.listProviders !== 'function') {
139
+ return { at, pairs: [], failures: [{ provider: '*', reason: 'llm-unavailable', retryable: true }], stale: false, servedFromCache: false, unavailable: true }
140
+ }
141
+ const pairs = []
142
+ const failures = []
143
+ let providers = []
144
+ try {
145
+ providers = await llm.listProviders()
146
+ } catch (e) {
147
+ // 整个目录不可用:不缓存成"空目录"结论,只记一次失败(下次仍尝试)。
148
+ return { at, pairs: [], failures: [{ provider: '*', reason: 'list-providers-failed', retryable: true }], stale: false, servedFromCache: false, unavailable: true }
149
+ }
150
+ if (!Array.isArray(providers)) providers = []
151
+ for (const raw of providers.slice(0, MAX_PROVIDERS)) {
152
+ const provider = typeof raw === 'string' ? raw : (raw !== null && typeof raw === 'object' && typeof raw.id === 'string' ? raw.id : '')
153
+ if (provider === '') continue
154
+ const failed = providerFailures.get(provider)
155
+ if (failed !== undefined && at - failed.at < failureTtlMs && !force) {
156
+ failures.push({ provider, reason: failed.reason, retryable: true, cachedFailure: true })
157
+ continue
158
+ }
159
+ try {
160
+ // 有界目录发现:单个 provider 挂死不能拖住其它候选(第一版验收反例:首 provider 不返回 → 整次 snapshot 不返回)。
161
+ const models = await withDeadline(Promise.resolve(llm.listModels(provider)), providerTimeoutMs)
162
+ if (!Array.isArray(models)) throw new Error('listModels did not return an array')
163
+ const seen = new Set()
164
+ let added = 0
165
+ for (const entry of models.slice(0, MAX_MODELS_PER_PROVIDER)) {
166
+ const model = modelIdOf(entry)
167
+ if (model === '' || seen.has(model)) continue // 同 provider 重复 id 只留一条
168
+ seen.add(model)
169
+ pairs.push({
170
+ provider, model, key: pairKey(provider, model),
171
+ name: typeof entry === 'object' && entry !== null && typeof entry.name === 'string' ? entry.name : model,
172
+ ...(typeof entry === 'object' && entry !== null && typeof entry.description === 'string' ? { description: entry.description } : {}),
173
+ ...(typeof entry === 'object' && entry !== null && Array.isArray(entry.inputModalities) ? { inputModalities: entry.inputModalities.filter((m) => typeof m === 'string') } : {}),
174
+ order: pairs.length,
175
+ })
176
+ added++
177
+ }
178
+ providerFailures.delete(provider)
179
+ if (added === 0) failures.push({ provider, reason: 'no-models', retryable: false })
180
+ } catch (e) {
181
+ // **失败隔离**:只影响该 provider,其它 provider 的候选照常可用。
182
+ const reason = typeof e === 'object' && e !== null && typeof e.code === 'string' ? e.code : 'list-models-failed'
183
+ providerFailures.set(provider, { at, reason })
184
+ failures.push({ provider, reason, retryable: true })
185
+ }
186
+ }
187
+ cache = { at, pairs, failures }
188
+ return { at, pairs, failures, stale: false, servedFromCache: false }
189
+ }
190
+
191
+ /** 解析单 pair 能力(有界缓存;失败**不**缓存成永久未知)。 */
192
+ const resolveInfo = async (provider, model) => {
193
+ const key = pairKey(provider, model)
194
+ const at = now()
195
+ const hit = infoCache.get(key)
196
+ if (hit !== undefined && at - hit.at < ttlMs) return { ok: true, info: hit.info, capability: hit.capability }
197
+ if (llm === undefined || llm === null || typeof llm.resolveModelInfo !== 'function') return { ok: false, reason: 'llm-unavailable' }
198
+ try {
199
+ const info = await llm.resolveModelInfo(provider, model)
200
+ const capability = capabilityOf(info)
201
+ infoCache.set(key, { at, info, capability })
202
+ // 有界:最多 512 条
203
+ if (infoCache.size > 512) infoCache.delete(infoCache.keys().next().value)
204
+ return { ok: true, info, capability }
205
+ } catch (e) {
206
+ const reason = typeof e === 'object' && e !== null && typeof e.code === 'string' ? e.code : 'resolve-model-info-failed'
207
+ return { ok: false, reason }
208
+ }
209
+ }
210
+
211
+ return { snapshot, resolveInfo, invalidate, _cache: () => cache }
212
+ }
213
+
214
+ /** 名称提示(可解释,不当作质量/价格承诺):fast/pro 在模型名上的弱信号。 */
215
+ const nameHints = (model) => {
216
+ const id = String(model).toLowerCase()
217
+ const hints = []
218
+ if (/flash|lite|fast|mini|light|small/.test(id)) hints.push('fast')
219
+ if (/pro|max|ultra|large|reason/.test(id)) hints.push('pro')
220
+ return hints
221
+ }
222
+ const isDeepSeek = (provider) => /deepseek/i.test(String(provider))
223
+ /** DeepSeek 偏好同时看 provider 与**模型 id/name**(自定义 provider 下的 DeepSeek 模型也要优先)。 */
224
+ const isDeepSeekPair = (pair) => /deepseek/i.test(String(pair.provider)) || /deepseek/i.test(String(pair.model)) || /deepseek/i.test(String(pair.name === undefined ? '' : pair.name))
225
+
226
+ /**
227
+ * 初次选择:先满足用户显式约束与真实能力,再按 DeepSeek 偏好 + 任务类型 + 名称提示排序。
228
+ * 返回稳定形态,绝不静默选错:未知/歧义 → 明确 reason + candidates。
229
+ * `needs.excludeKeys` / `excludeKey`:已选中的 pair 不重复进入备选池。
230
+ */
231
+ export const selectRoute = async ({ catalog, request = {}, resolveInfo }) => {
232
+ if (catalog === undefined || catalog === null) return { ok: false, reason: 'catalog-unavailable' }
233
+ const snap = await catalog.snapshot()
234
+ const mode = ROUTE_MODES.includes(request.mode) ? request.mode : 'auto'
235
+ const failures = snap.failures || []
236
+ const needs = request.needs !== null && typeof request.needs === 'object' ? request.needs : {}
237
+
238
+ /** 候选排序(auto/fast/pro 与 exact 的备选池共用同一套能力过滤与偏好)。 */
239
+ const rank = async ({ excludeKey } = {}) => {
240
+ const scored = []
241
+ for (const pair of snap.pairs) {
242
+ if (excludeKey !== undefined && pair.key === excludeKey) continue
243
+ const info = resolveInfo !== undefined ? await resolveInfo(pair.provider, pair.model) : { ok: false, reason: 'no-resolver' }
244
+ // 已证不可用(结构化解析失败码):**排除**候选,绝不降级成"未知能力"再送进准备期失败。
245
+ if (info.ok !== true && typeof info.reason === 'string' && UNRESOLVABLE_CAPABILITY_CODES.has(info.reason)) {
246
+ scored.push({ pair, rejected: 'capability-unresolvable:' + info.reason, reasons: ['capability-unresolvable'] })
247
+ continue
248
+ }
249
+ const cap = info.ok === true ? info.capability : { known: false, unresolvedReason: info.reason }
250
+ const reasons = []
251
+ let rejected = null
252
+ if (Array.isArray(needs.inputModalities) && needs.inputModalities.length > 0) {
253
+ if (Array.isArray(cap.inputModalities)) {
254
+ const missing = needs.inputModalities.filter((m) => !cap.inputModalities.includes(m))
255
+ if (missing.length > 0) rejected = 'modality-unsupported'
256
+ } else if (needs.requireKnownCapabilities === true) rejected = 'modality-unknown'
257
+ }
258
+ if (rejected === null && Number.isFinite(needs.contextWindow) && Number.isFinite(cap.contextWindow) && cap.contextWindow < needs.contextWindow) rejected = 'context-too-small'
259
+ if (rejected === null && typeof needs.effort === 'string' && Array.isArray(cap.efforts) && !cap.efforts.includes(needs.effort)) rejected = 'effort-unsupported'
260
+ if (rejected !== null) { scored.push({ pair, rejected, reasons: [rejected] }); continue }
261
+ let score = 0
262
+ if (isDeepSeekPair(pair)) { score += 100; reasons.push('deepseek-preferred') }
263
+ const hints = nameHints(pair.model)
264
+ if (mode === 'fast' && hints.includes('fast')) { score += 20; reasons.push('fast-hint') }
265
+ if (mode === 'pro' && hints.includes('pro')) { score += 20; reasons.push('pro-hint') }
266
+ if (request.taskKind === 'review' && /pro|max|reason/.test(String(pair.model).toLowerCase())) { score += 5; reasons.push('review-prefers-stronger-name') }
267
+ if (cap.known !== true) reasons.push('capability-unknown')
268
+ scored.push({ pair, score, reasons, order: pair.order, capability: cap })
269
+ }
270
+ const usable = scored.filter((s) => s.rejected === undefined).sort((a, b) => (b.score - a.score) || (a.order - b.order))
271
+ return { scored, usable }
272
+ }
273
+
274
+ /** exact:也要给出**备选池**(显式 modelFallback:true 才有池可用;默认空池=固定)。 */
275
+ const poolFor = async (pair) => {
276
+ if (request.allowFallback === false) return []
277
+ const ranked = await rank({ excludeKey: pair.key })
278
+ return ranked.usable.slice(0, 3).map((s) => ({ provider: s.pair.provider, model: s.pair.model, score: s.score, key: s.pair.key }))
279
+ }
280
+
281
+ if (mode === 'inherit') {
282
+ // default(继承):不覆盖当前选择;若调用方**显式**允许 fallback,则预先算好"失败时可用"的池
283
+ // (首 attempt 用机器实际配置,失败后才有备选可用)。
284
+ if (request.allowFallback !== true) return { ok: true, mode, inherit: true, reason: 'explicit-inherit' }
285
+ const ranked = await rank()
286
+ return {
287
+ ok: true, mode, inherit: true, reason: 'inherit-with-fallback-pool', initial: null,
288
+ alternatives: ranked.usable.slice(0, 3).map((s) => ({ provider: s.pair.provider, model: s.pair.model, score: s.score, key: s.pair.key })),
289
+ failures,
290
+ }
291
+ }
292
+
293
+ // 精确指定:provider+model 必须都在目录内(未给 provider 且同名歧义 → 拒绝并给候选)
294
+ if (mode === 'exact' || (typeof request.model === 'string' && request.model !== '')) {
295
+ const model = typeof request.model === 'string' ? request.model : ''
296
+ if (model === '') return { ok: false, reason: 'model-required' }
297
+ if (typeof request.provider === 'string' && request.provider !== '') {
298
+ const hit = snap.pairs.find((p) => p.provider === request.provider && p.model === model)
299
+ if (hit === undefined) {
300
+ const sameModel = snap.pairs.filter((p) => p.model === model)
301
+ return { ok: false, reason: 'exact-pair-not-in-catalog', provider: request.provider, model, candidates: sameModel }
302
+ }
303
+ return { ok: true, mode: 'exact', pair: hit, reason: 'exact-pair', alternatives: await poolFor(hit), failures }
304
+ }
305
+ const matches = snap.pairs.filter((p) => p.model === model)
306
+ if (matches.length === 0) return { ok: false, reason: 'model-not-in-catalog', model, candidates: [] }
307
+ if (matches.length > 1) {
308
+ // 同名跨 provider:**不静默合并**,要求调用方给出 provider
309
+ return { ok: false, reason: 'model-ambiguous-across-providers', model, candidates: matches }
310
+ }
311
+ return { ok: true, mode: 'exact', pair: matches[0], reason: 'exact-model-unique', alternatives: await poolFor(matches[0]), failures }
312
+ }
313
+
314
+ // auto/fast/pro:按能力过滤 + 偏好排序
315
+ const { scored, usable } = await rank()
316
+ if (usable.length === 0) {
317
+ return {
318
+ ok: false, mode, reason: 'no-compatible-candidate',
319
+ rejected: scored.filter((s) => s.rejected !== undefined).slice(0, 10).map((s) => ({ provider: s.pair.provider, model: s.pair.model, rejected: s.rejected })),
320
+ failures,
321
+ }
322
+ }
323
+ const best = usable[0]
324
+ // 目录列了但 pair 无法解析能力时的诚实标注(不是"已验证可调用")
325
+ return {
326
+ ok: true, mode, pair: best.pair, reason: best.reasons.includes('deepseek-preferred') ? 'deepseek-preferred' : (best.reasons[0] || 'stable-order'),
327
+ reasons: best.reasons, capabilityKnown: best.capability !== undefined && best.capability.known === true,
328
+ alternatives: usable.slice(1, 4).map((s) => ({ provider: s.pair.provider, model: s.pair.model, score: s.score })),
329
+ failures, catalogAt: snap.at,
330
+ }
331
+ }
332
+
333
+ /** 失败分类:只用稳定 code/status。 */
334
+ export const classifyFailure = (failure) => {
335
+ const code = failure !== null && typeof failure === 'object' && typeof failure.code === 'string' ? failure.code.toUpperCase() : ''
336
+ const status = failure !== null && typeof failure === 'object' && Number.isFinite(failure.status) ? failure.status : undefined
337
+ const retryAfterMs = failure !== null && typeof failure === 'object' && Number.isFinite(failure.providerRetryAfterMs) ? failure.providerRetryAfterMs : undefined
338
+ if (code !== '') {
339
+ if (NEVER_SWITCH_CODES.has(code)) return { action: 'never', code, reason: 'not-a-model-availability-failure' }
340
+ if (SWITCHABLE_CODES.has(code)) return { action: 'switch', code, reason: 'structured-unavailable' }
341
+ if (RETRYABLE_CODES.has(code)) return { action: 'retry-then-switch', code, ...(retryAfterMs === undefined ? {} : { retryAfterMs }), reason: 'transient' }
342
+ // HTTP_404:只说明该路由不可用,**不断言**模型下架
343
+ if (/^HTTP_4\d\d$/.test(code)) return { action: code === 'HTTP_404' || code === 'HTTP_401' || code === 'HTTP_403' || code === 'HTTP_402' ? 'switch' : 'never', code, reason: code === 'HTTP_404' ? 'route-unavailable' : 'request-rejected' }
344
+ if (/^HTTP_5\d\d$/.test(code)) return { action: 'retry-then-switch', code, ...(retryAfterMs === undefined ? {} : { retryAfterMs }), reason: 'server' }
345
+ return { action: 'never', code, reason: 'unknown-code' } // 未知 code:不切(可能是插件 bug)
346
+ }
347
+ if (status !== undefined) {
348
+ if (status === 401 || status === 403 || status === 402 || status === 404) return { action: 'switch', code: 'HTTP_' + status, reason: 'structured-unavailable' }
349
+ if (status === 429) return { action: 'retry-then-switch', code: 'HTTP_429', ...(retryAfterMs === undefined ? {} : { retryAfterMs }), reason: 'rate-limit' }
350
+ if (status >= 500) return { action: 'retry-then-switch', code: 'HTTP_' + status, ...(retryAfterMs === undefined ? {} : { retryAfterMs }), reason: 'server' }
351
+ return { action: 'never', code: 'HTTP_' + status, reason: 'request-rejected' }
352
+ }
353
+ return { action: 'never', code: '', reason: 'no-structured-failure' }
354
+ }
355
+
356
+ /** 有界切换策略默认值。 */
357
+ export const DEFAULT_ROUTE_LIMITS = {
358
+ maxCandidates: 3, // 含首次
359
+ maxAttemptsPerPair: 2,
360
+ perPairTimeoutMs: 120_000,
361
+ totalTimeoutMs: 300_000,
362
+ cooldownMs: 30_000,
363
+ }
364
+
365
+ /**
366
+ * 任务作用域的有界切换状态机(同一 taskId/turn/step 内使用;不重派 prompt、不新建 child)。
367
+ * `next()` 只回答"这一次 attempt 用什么 pair / 是否还能继续",绝不做任何 SDK 副作用。
368
+ *
369
+ * **预算语义(第 55 轮修正)**:`totalTimeoutMs`/`perPairTimeoutMs` 是**故障恢复预算**,
370
+ * 从"恢复 episode"(第一次故障)开始计时,**不**从建任务时计时——否则长健康任务会把预算耗光,
371
+ * 五分钟后的首次故障无法切换。恢复成功(下一 step/turn 的 attempt 未经过失败)会关闭 episode;
372
+ * 下次故障重新开 episode。retry-after 等待走真实时钟,自然计入恢复预算(不会每次 retry 重置)。
373
+ */
374
+ export const createRouteSession = (options = {}) => {
375
+ const limits = { ...DEFAULT_ROUTE_LIMITS, ...(options.limits || {}) }
376
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
377
+ const cooldowns = options.cooldowns instanceof Map ? options.cooldowns : new Map()
378
+ const startedAt = now()
379
+ /** 入参 pair 可能只有 {provider,model}:**一律归一化**出 key(否则 attempted/cooldowns 会以 undefined 为键)。 */
380
+ const normalize = (pair) => {
381
+ if (pair === null || pair === undefined) return null
382
+ if (typeof pair.provider !== 'string' || typeof pair.model !== 'string') return null
383
+ return pairOf(pair.provider, pair.model)
384
+ }
385
+ let current = normalize(options.initial)
386
+ const attempted = new Map() // pairKey → attempts
387
+ const usedOrder = [] // 已尝试过的 pair(按顺序,去重)
388
+ const switches = []
389
+ let lastFailure = null
390
+ let lastFailureAt = null // 最近一次失败时刻(用于判断"恢复是否已成功")
391
+ /** 当前恢复 episode:第一次故障开启;恢复成功后关闭。null = 尚未进入恢复。 */
392
+ let episodeStartedAt = null
393
+ /** 本 episode 内每个 pair 首次故障的时刻(perPairTimeout 的真实消费者)。 */
394
+ const pairRecoveryAt = new Map()
395
+ /** 上一次 attempt 的 turn/step(判断"健康推进"用)。 */
396
+ let lastAttempt = null
397
+ let remaining = Array.isArray(options.alternatives) ? options.alternatives.map(normalize).filter((p) => p !== null) : []
398
+
399
+ const closeEpisode = () => { episodeStartedAt = null; pairRecoveryAt.clear(); lastFailureAt = null }
400
+
401
+ const pairCooldown = (pair) => {
402
+ const until = cooldowns.get(pair.key)
403
+ return Number.isFinite(until) && until > now() ? until : 0
404
+ }
405
+ const addCooldown = (pair, ms) => {
406
+ if (!Number.isFinite(ms) || ms <= 0) return
407
+ cooldowns.set(pair.key, now() + Math.min(ms, 10 * 60_000))
408
+ }
409
+ const distinctCount = () => usedOrder.length
410
+
411
+ /**
412
+ * 记录一次 attempt 的实际使用(由 bridge 在 agent/request 返回前调用)。
413
+ * `position` 可选 `{turn, step}`:若上一 attempt 之后**没有失败**且 turn/step 前进 → 说明上一 attempt
414
+ * 已成功、任务健康推进 → 关闭恢复 episode(下次故障重新获得完整恢复预算)。
415
+ */
416
+ const recordAttempt = (pair, position) => {
417
+ const normalized = normalize(pair)
418
+ if (normalized === null) return
419
+ const turn = position !== null && typeof position === 'object' && Number.isInteger(position.turn) ? position.turn : undefined
420
+ const step = position !== null && typeof position === 'object' && Number.isInteger(position.step) ? position.step : undefined
421
+ // 位置前进 = 上一 attempt 真的产出了内容(step/turn 推进),说明恢复已成功 → 关闭 episode。
422
+ // 注意:**不能**再要求 lastFailureAt===null —— 恢复过程里必然有失败,正是"失败后位置前进"才算恢复成功;
423
+ // 同 turn/step 的重试/切换不会推进位置,因此不会误关。
424
+ if (lastAttempt !== null && Number.isInteger(turn) && Number.isInteger(step) && Number.isInteger(lastAttempt.turn) && Number.isInteger(lastAttempt.step)) {
425
+ const progressed = turn > lastAttempt.turn || (turn === lastAttempt.turn && step > lastAttempt.step)
426
+ if (progressed) closeEpisode()
427
+ }
428
+ const count = (attempted.get(normalized.key) || 0) + 1
429
+ attempted.set(normalized.key, count)
430
+ if (!usedOrder.some((p) => p.key === normalized.key)) usedOrder.push(normalized)
431
+ current = normalized
432
+ lastAttempt = { turn, step }
433
+ }
434
+
435
+ /**
436
+ * 收到结构化失败后决定下一步。返回:
437
+ * { action:'retry', pair, reason } → 同 turn/step 重试(可能是同 pair 有界重试,也可能是切换后的新 pair)
438
+ * { action:'stop', reason, exhausted } → 如实失败(不 done、不空输出、不重派)
439
+ */
440
+ const onFailure = (failure, context = {}) => {
441
+ const classified = classifyFailure(failure)
442
+ lastFailure = classified
443
+ const at = now()
444
+ if (context.aborted === true) return { action: 'stop', reason: 'aborted', exhausted: false }
445
+ if (classified.action === 'never') return { action: 'stop', reason: classified.reason, code: classified.code, exhausted: false }
446
+ // ---- 恢复 episode:第一次故障开启;只算**恢复活动**时长,不算任务健康运行时长 ----
447
+ if (episodeStartedAt === null) episodeStartedAt = at
448
+ lastFailureAt = at
449
+ const currentPair = current
450
+ if (currentPair !== null && !pairRecoveryAt.has(currentPair.key)) pairRecoveryAt.set(currentPair.key, at)
451
+ // 总恢复预算(含 retry-after 等待的真实时间)
452
+ if (at - episodeStartedAt >= limits.totalTimeoutMs) return { action: 'stop', reason: 'total-time-limit', exhausted: true }
453
+ // 单 pair 恢复时限:该 pair 在本次恢复 episode 里已耗掉 perPairTimeoutMs → 不再重试它
454
+ const pairStart = currentPair === null ? null : pairRecoveryAt.get(currentPair.key)
455
+ const pairTimeUp = currentPair !== null && Number.isInteger(pairStart) && (at - pairStart >= limits.perPairTimeoutMs)
456
+ // 1) 同一 pair 的有界重试(限流/网络/服务端),且该 pair 的恢复时限未到
457
+ if (classified.action === 'retry-then-switch' && currentPair !== null && !pairTimeUp) {
458
+ const used = attempted.get(currentPair.key) || 0
459
+ if (used < limits.maxAttemptsPerPair) {
460
+ return { action: 'retry', pair: currentPair, reason: 'bounded-retry:' + classified.reason, ...(classified.retryAfterMs === undefined ? {} : { retryAfterMs: classified.retryAfterMs }) }
461
+ }
462
+ }
463
+ // 2) 切换到下一个不同候选(perPair 时限到/该 pair 次数用完/结构化不可用都走这里)
464
+ if (classified.action === 'switch' || classified.action === 'retry-then-switch') {
465
+ if (currentPair !== null) addCooldown(currentPair, classified.retryAfterMs !== undefined ? classified.retryAfterMs : limits.cooldownMs)
466
+ if (distinctCount() >= limits.maxCandidates) return { action: 'stop', reason: 'candidate-limit', exhausted: true }
467
+ while (remaining.length > 0) {
468
+ const candidate = remaining.shift()
469
+ if (attempted.has(candidate.key)) continue // 不重复选已失败候选(不绕圈)
470
+ const until = pairCooldown(candidate)
471
+ if (until > at) continue // 临时冷却中的不选
472
+ switches.push({ at, from: currentPair === null ? null : { provider: currentPair.provider, model: currentPair.model }, to: { provider: candidate.provider, model: candidate.model }, code: classified.code, reason: pairTimeUp ? 'per-pair-time-limit' : classified.reason })
473
+ current = candidate
474
+ return { action: 'retry', pair: candidate, reason: pairTimeUp ? 'switch:per-pair-time-limit' : 'switch:' + classified.reason, switched: true }
475
+ }
476
+ return { action: 'stop', reason: 'candidates-exhausted', exhausted: true }
477
+ }
478
+ return { action: 'stop', reason: classified.reason, code: classified.code, exhausted: false }
479
+ }
480
+
481
+ const summary = () => ({
482
+ attempts: attempted.size,
483
+ uses: Array.from(attempted.entries()),
484
+ switches: switches.length,
485
+ switchLog: switches.slice(0, 20),
486
+ lastFailure,
487
+ initial: options.initial === undefined ? null : options.initial,
488
+ current: current === null ? null : { provider: current.provider, model: current.model },
489
+ /** 任务存活时长(仅诊断;**不**参与恢复预算)。 */
490
+ elapsedMs: now() - startedAt,
491
+ /** 当前恢复 episode 已用时长(没有 episode 时为 0)。 */
492
+ recoveryElapsedMs: episodeStartedAt === null ? 0 : now() - episodeStartedAt,
493
+ episodeOpen: episodeStartedAt !== null,
494
+ })
495
+
496
+ return { recordAttempt, onFailure, summary, limits, _state: () => ({ attempted, usedOrder, remaining }) }
497
+ }
498
+
499
+ /** 供审计/状态回显使用的非秘密摘要。 */
500
+ export const routeSummaryOf = (session) => (session === null || session === undefined ? null : session.summary())