dsh-llm-fallbacks 0.3.4 → 0.4.0-alpha.1

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/dist/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { createRequire } from "node:module";
2
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
2
  import z from "@deepseek-ai/schemastery";
4
3
  import { LlmAdapter, LlmError, createUserMessage } from "@deepseek-ai/dsh-llm";
5
4
  import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
@@ -259,6 +258,65 @@ function resolveSlotState(config, now, tz) {
259
258
  label: "all-day"
260
259
  };
261
260
  }
261
+ const ESCALATION_CAP_MS = 36e5;
262
+ function escalatedCooldownMs(ms, n) {
263
+ return Math.max(ms, Math.min(ESCALATION_CAP_MS, ms * 2 ** (n - 1)));
264
+ }
265
+ var RecoveryStore = class {
266
+ entries = new Map();
267
+ recordFailure(key) {
268
+ const entry = this.entries.get(key);
269
+ if (entry === void 0) {
270
+ this.entries.set(key, {
271
+ consecutiveFailures: 1,
272
+ halfOpen: false,
273
+ probeLogged: false
274
+ });
275
+ return 1;
276
+ }
277
+ entry.consecutiveFailures += 1;
278
+ entry.halfOpen = false;
279
+ entry.probeLogged = false;
280
+ return entry.consecutiveFailures;
281
+ }
282
+ markHalfOpen(key, until) {
283
+ const entry = this.entries.get(key);
284
+ if (entry === void 0) {
285
+ this.entries.set(key, {
286
+ consecutiveFailures: 0,
287
+ halfOpen: true,
288
+ probeLogged: false,
289
+ expiredUntil: until
290
+ });
291
+ return;
292
+ }
293
+ entry.halfOpen = true;
294
+ entry.probeLogged = false;
295
+ entry.expiredUntil = until;
296
+ }
297
+ tryMarkProbeLogged(key) {
298
+ const entry = this.entries.get(key);
299
+ if (entry === void 0 || !entry.halfOpen || entry.probeLogged) return false;
300
+ entry.probeLogged = true;
301
+ return true;
302
+ }
303
+ isHalfOpen(key) {
304
+ return this.entries.get(key)?.halfOpen === true;
305
+ }
306
+ close(key) {
307
+ const entry = this.entries.get(key);
308
+ if (entry === void 0 || !entry.halfOpen) return;
309
+ this.entries.delete(key);
310
+ }
311
+ halfOpenEntries() {
312
+ const out = [];
313
+ for (const [key, entry] of this.entries) if (entry.halfOpen && entry.expiredUntil !== void 0) out.push({
314
+ key,
315
+ untilEpochMs: entry.expiredUntil
316
+ });
317
+ return out;
318
+ }
319
+ };
262
320
  //#endregion
263
321
  //#region .build/host/config.js
264
322
  const defaultFallbacksConfig = {
@@ -280,7 +338,8 @@ const defaultFallbacksConfig = {
280
338
  presets: "bundled",
281
339
  roleAutoMatch: true,
282
340
  timeSlots: [],
283
- tz: "Asia/Shanghai"
341
+ tz: "Asia/Shanghai",
342
+ recovery: "timer"
284
343
  };
285
344
  const INHERIT_ROLE_ID = "inherit";
286
345
  const ROLE_ID_PATTERN = /^[a-z0-9-]{1,32}$/;
@@ -325,6 +384,7 @@ function validateFallbacksConfig(config, logger) {
325
384
  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`);
326
385
  }
327
386
  }
387
+ 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`);
328
388
  }
329
389
  function detectLegacyKeys(source) {
330
390
  const keys = [];
@@ -394,7 +454,8 @@ const Config = z.object({
394
454
  days: z.array(z.number()),
395
455
  chain: z.array(z.string())
396
456
  })).default([]),
397
- tz: z.string().default("Asia/Shanghai")
457
+ tz: z.string().default("Asia/Shanghai"),
458
+ recovery: z.union([z.const("timer"), z.const("half-open")]).default("timer")
398
459
  });
399
460
  function buildAutomatchPrompt(roles, agent) {
400
461
  const lines = [
@@ -633,6 +694,98 @@ function firstExactCandidate(all, wildcard) {
633
694
  for (let index = 0; index < all.length; index += 1) if (!wildcard[index]) return all[index];
634
695
  }
635
696
  //#endregion
697
+ //#region .build/host/authorized-route.js
698
+ function completeRoute(source) {
699
+ if (typeof source !== "object" || source === null || typeof source.provider !== "string" || source.provider.length === 0 || typeof source.model !== "string" || source.model.length === 0) return;
700
+ return {
701
+ provider: source.provider,
702
+ model: source.model
703
+ };
704
+ }
705
+ function lastModelSelection(entries) {
706
+ if (entries === void 0) return void 0;
707
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
708
+ const candidate = entries[index];
709
+ if (candidate?.type !== "model/selection") continue;
710
+ const route = completeRoute(candidate.data);
711
+ if (route !== void 0) return route;
712
+ }
713
+ }
714
+ function detectAuthorizedRoute(session) {
715
+ const { events, requestHeader, options, inherited } = session;
716
+ const selected = lastModelSelection(events);
717
+ if (selected !== void 0) return selected;
718
+ const route = completeRoute(requestHeader) ?? completeRoute(options);
719
+ if (route === void 0) return void 0;
720
+ const baseline = completeRoute(inherited);
721
+ if (baseline !== void 0 && route.provider === baseline.provider && route.model === baseline.model) return;
722
+ return route;
723
+ }
724
+ //#endregion
725
+ //#region .build/host/route-allowlist.js
726
+ function resolvedRoutes(candidates) {
727
+ const routes = [];
728
+ for (const candidate of candidates) {
729
+ if (candidate.model === void 0) continue;
730
+ routes.push({
731
+ provider: candidate.provider,
732
+ model: candidate.model
733
+ });
734
+ }
735
+ return routes;
736
+ }
737
+ function routeOnAllowlist(route, allowedModels) {
738
+ return allowedModels.some((allowed) => allowed.provider === route.provider && allowed.model === route.model);
739
+ }
740
+ function firstAllowedCandidate(resolved, allowedModels) {
741
+ return resolved.find((route) => routeOnAllowlist(route, allowedModels));
742
+ }
743
+ //#endregion
744
+ //#region .build/host/subagent-policy.js
745
+ function parseRoutes(value) {
746
+ if (!Array.isArray(value)) return void 0;
747
+ const routes = [];
748
+ for (const candidate of value) {
749
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate) || !("provider" in candidate) || typeof candidate.provider !== "string" || candidate.provider.length === 0 || !("model" in candidate) || typeof candidate.model !== "string" || candidate.model.length === 0) return;
750
+ routes.push({
751
+ provider: candidate.provider,
752
+ model: candidate.model
753
+ });
754
+ }
755
+ return routes;
756
+ }
757
+ function readSessionPolicyEvent(events) {
758
+ const event = events.find((candidate) => candidate.type === "subagent/model-selection-policy");
759
+ if (event === void 0) return {
760
+ ok: false,
761
+ present: false
762
+ };
763
+ const data = event.data;
764
+ const routes = parseRoutes(typeof data === "object" && data !== null && "allowedModels" in data ? data.allowedModels : void 0);
765
+ if (routes === void 0 || routes.length === 0) return {
766
+ ok: false,
767
+ present: true
768
+ };
769
+ return {
770
+ ok: true,
771
+ allowedModels: routes
772
+ };
773
+ }
774
+ function effectivePolicy(event, settings) {
775
+ if (event.ok) return {
776
+ state: "enabled",
777
+ allowedModels: event.allowedModels
778
+ };
779
+ if (event.present) return { state: "unprovable" };
780
+ if (settings === void 0 || settings.enabled !== true) return { state: "disabled" };
781
+ const routes = parseRoutes(settings.allowedModels);
782
+ if (routes === void 0 || routes.length === 0) return { state: "unprovable" };
783
+ return {
784
+ state: "enabled",
785
+ allowedModels: routes
786
+ };
787
+ }
788
+ //#endregion
636
789
  //#region .build/host/cooldown.js
637
790
  var CooldownStore = class {
638
791
  entries = new Map();
@@ -651,6 +804,12 @@ var CooldownStore = class {
651
804
  }
652
805
  return true;
653
806
  }
807
+ peek(key) {
808
+ return this.entries.get(key);
809
+ }
810
+ keys() {
811
+ return this.entries.keys();
812
+ }
654
813
  snapshot(now = Date.now()) {
655
814
  const active = [];
656
815
  for (const [key, until] of this.entries) {
@@ -701,7 +860,8 @@ var FallbackStateStore = class {
701
860
  failed: new StepFailureSet(),
702
861
  switchCount: 0
703
862
  },
704
- cooldown: new CooldownStore()
863
+ cooldown: new CooldownStore(),
864
+ recovery: new RecoveryStore()
705
865
  };
706
866
  this.states.set(agentId, state);
707
867
  }
@@ -752,13 +912,47 @@ var FallbackStateStore = class {
752
912
  suppress(state, key, untilEpochMs) {
753
913
  state.cooldown.suppress(key, untilEpochMs);
754
914
  }
755
- isSuppressed(state, key, now = Date.now()) {
756
- return state.cooldown.isSuppressed(key, now);
915
+ isSuppressed(state, key, now = Date.now(), recovery = "timer") {
916
+ if (recovery !== "half-open") return state.cooldown.isSuppressed(key, now);
917
+ const until = state.cooldown.peek(key);
918
+ if (until === void 0) return false;
919
+ if (until > now) return true;
920
+ state.cooldown.isSuppressed(key, now);
921
+ state.recovery.markHalfOpen(key, until);
922
+ return false;
923
+ }
924
+ syncRecovery(state, now, mode) {
925
+ if (mode !== "half-open") return;
926
+ for (const key of state.cooldown.keys()) this.isSuppressed(state, key, now, mode);
927
+ }
928
+ observeSuccess(state, key) {
929
+ state.recovery.close(key);
757
930
  }
758
931
  };
759
932
  //#endregion
933
+ //#region .build/host/override.js
934
+ function overrideConfigWithRouteRule(seed, to, explicitEffort) {
935
+ if (explicitEffort !== void 0) return {
936
+ ...seed,
937
+ reasoningEffort: explicitEffort,
938
+ provider: to.provider,
939
+ model: to.model
940
+ };
941
+ if (!(to.provider !== seed.provider || to.model !== seed.model)) return {
942
+ ...seed,
943
+ provider: to.provider,
944
+ model: to.model
945
+ };
946
+ const { reasoningEffort: _routeOwned, ...withoutRouteOwnedEffort } = seed;
947
+ return {
948
+ ...withoutRouteOwnedEffort,
949
+ provider: to.provider,
950
+ model: to.model
951
+ };
952
+ }
953
+ //#endregion
760
954
  //#region .build/host/gateway.js
761
- const FALLBACKS_SETTINGS_NAMESPACE = settingsNamespace("fallbacks");
955
+ const FALLBACKS_SETTINGS_NAMESPACE = "fallbacks";
762
956
  const CONFIG_KEYS = {
763
957
  enabled: true,
764
958
  triggerCodes: true,
@@ -771,7 +965,8 @@ const CONFIG_KEYS = {
771
965
  presets: true,
772
966
  roleAutoMatch: true,
773
967
  timeSlots: true,
774
- tz: true
968
+ tz: true,
969
+ recovery: true
775
970
  };
776
971
  const ROLES_KEYS = {
777
972
  list: true,
@@ -791,10 +986,12 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
791
986
  bridge;
792
987
  seeds;
793
988
  settings;
794
- constructor(ctx, bridge, seeds) {
989
+ subagentPolicy;
990
+ constructor(ctx, bridge, seeds, subagentPolicy) {
795
991
  super(ctx, "fallbacks");
796
992
  this.bridge = bridge;
797
993
  this.seeds = seeds;
994
+ this.subagentPolicy = subagentPolicy;
798
995
  ctx.inject(["settings"], (sctx) => {
799
996
  this.settings = sctx.settings;
800
997
  return () => {
@@ -840,11 +1037,14 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
840
1037
  }
841
1038
  readResult() {
842
1039
  const source = this.bridge.source();
843
- return {
1040
+ const result = {
844
1041
  config: this.readConfig(source),
845
1042
  legacyKeys: detectLegacyKeys(source),
846
1043
  seeds: this.seeds.wireStatus(this.seedsIo())
847
1044
  };
1045
+ const projected = projectSubagentPolicy(this.subagentPolicy);
1046
+ if (projected !== void 0) result.subagentPolicy = projected;
1047
+ return result;
848
1048
  }
849
1049
  seedsIo() {
850
1050
  const settings = this.settings;
@@ -857,6 +1057,41 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
857
1057
  };
858
1058
  }
859
1059
  };
1060
+ function projectSubagentPolicy(snapshot) {
1061
+ if (snapshot === void 0) return void 0;
1062
+ let captured;
1063
+ try {
1064
+ captured = snapshot();
1065
+ } catch {
1066
+ return { state: "unprovable" };
1067
+ }
1068
+ const { policy, head, blockedAttempt } = captured;
1069
+ if (policy.state === "disabled") return { state: "disabled" };
1070
+ if (policy.state === "unprovable") return { state: "unprovable" };
1071
+ const wire = {
1072
+ state: "enabled",
1073
+ allowedModels: policy.allowedModels.map((route) => ({
1074
+ provider: route.provider,
1075
+ model: route.model
1076
+ }))
1077
+ };
1078
+ if (head !== void 0) wire.head = {
1079
+ route: {
1080
+ provider: head.route.provider,
1081
+ model: head.route.model
1082
+ },
1083
+ source: head.source
1084
+ };
1085
+ if (blockedAttempt !== void 0) wire.blockedAttempt = {
1086
+ at: blockedAttempt.at,
1087
+ route: {
1088
+ provider: blockedAttempt.route.provider,
1089
+ model: blockedAttempt.route.model
1090
+ },
1091
+ reason: blockedAttempt.reason
1092
+ };
1093
+ return wire;
1094
+ }
860
1095
  function normalizeRoles(value) {
861
1096
  if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
862
1097
  const roles = {};
@@ -1005,6 +1240,7 @@ const FALLBACKS_COMMAND_LOCALES = {
1005
1240
  cooldownNone: "无活跃冷却",
1006
1241
  cooldownLine: "{key} 冷却至 {time}",
1007
1242
  cooldownNever: "{key} 会话内不再回主",
1243
+ cooldownHalfOpen: "{key} half-open(等待恢复探针)",
1008
1244
  reason: {
1009
1245
  "trigger-code": "触发码",
1010
1246
  "always-cap": "always 上限"
@@ -1059,6 +1295,7 @@ const FALLBACKS_COMMAND_LOCALES = {
1059
1295
  cooldownNone: "none active",
1060
1296
  cooldownLine: "{key} suppressed until {time}",
1061
1297
  cooldownNever: "{key} not reverting this session",
1298
+ cooldownHalfOpen: "{key} half-open (awaiting recovery probe)",
1062
1299
  reason: {
1063
1300
  "trigger-code": "trigger-code",
1064
1301
  "always-cap": "always-cap"
@@ -1151,6 +1388,7 @@ function formatSwitch(entry, t) {
1151
1388
  return t.switchLine.replace("{from}", from).replace("{to}", to).replace("{role}", entry.role).replace("{reason}", t.reason[entry.reason] ?? entry.reason);
1152
1389
  }
1153
1390
  function formatCooldown(entry, t) {
1391
+ if (entry.halfOpen === true) return t.cooldownHalfOpen.replace("{key}", entry.key);
1154
1392
  if (!Number.isFinite(entry.untilEpochMs)) return t.cooldownNever.replace("{key}", entry.key);
1155
1393
  return t.cooldownLine.replace("{key}", entry.key).replace("{time}", new Date(entry.untilEpochMs).toISOString());
1156
1394
  }
@@ -1954,6 +2192,15 @@ var FallbacksChainAdapter = class extends LlmAdapter {
1954
2192
  name: model
1955
2193
  };
1956
2194
  }
2195
+ imageRequestPricing(_provider, _model) {
2196
+ try {
2197
+ const head = effectiveHeadOf(this.readConfig(), new Date());
2198
+ if (head === void 0 || head.provider === "FallbacksChain") return void 0;
2199
+ return this.getLlm()?.imageRequestPricing(head.provider, head.model);
2200
+ } catch {
2201
+ return;
2202
+ }
2203
+ }
1957
2204
  stream(options) {
1958
2205
  const config = this.readConfig();
1959
2206
  const now = new Date();
@@ -2015,6 +2262,19 @@ const stateStores = new WeakMap();
2015
2262
  function stateStore(ctx) {
2016
2263
  return stateStores.get(ctx);
2017
2264
  }
2265
+ const blockedAttemptStores = new WeakMap();
2266
+ function blockedAttempts(ctx) {
2267
+ return blockedAttemptStores.get(ctx);
2268
+ }
2269
+ const chainHeadStores = new WeakMap();
2270
+ function chainHeads(ctx) {
2271
+ return chainHeadStores.get(ctx);
2272
+ }
2273
+ function latestByAt(map) {
2274
+ let latest;
2275
+ for (const value of map.values()) if (latest === void 0 || value.at >= latest.at) latest = value;
2276
+ return latest;
2277
+ }
2018
2278
  async function makeModelExists(ctx, providers) {
2019
2279
  const llm = ctx.get("llm");
2020
2280
  if (llm === void 0 || typeof llm.listModels !== "function") return () => true;
@@ -2051,12 +2311,49 @@ function slotWinnerKey(winner) {
2051
2311
  if (winner === "all-day") return "all-day";
2052
2312
  return winner.kind === "preset" ? `preset:${winner.preset}` : `custom:${winner.start}-${winner.end}`;
2053
2313
  }
2054
- function overrideConfig(seed, to) {
2055
- const { reasoningEffort: _inherited, ...withoutInheritedEffort } = seed;
2314
+ function overrideConfig(seed, to, explicitEffort) {
2315
+ return overrideConfigWithRouteRule(seed, to, explicitEffort);
2316
+ }
2317
+ function readSubagentSettings(ctx) {
2318
+ const service = ctx.get("subagentModelSelection");
2319
+ return service === void 0 ? void 0 : service.current();
2320
+ }
2321
+ function readGuardedPolicySettings(ctx, agentId, lastKnown, warn) {
2322
+ try {
2323
+ const service = ctx.get("subagentModelSelection");
2324
+ if (service === void 0) return {
2325
+ ok: true,
2326
+ settings: lastKnown.get(agentId)
2327
+ };
2328
+ const settings = service.current();
2329
+ lastKnown.set(agentId, settings);
2330
+ return {
2331
+ ok: true,
2332
+ settings
2333
+ };
2334
+ } catch (error) {
2335
+ warn("llm-fallbacks: agent \"%s\" subagent model-selection settings read failed — %s", agentId, error?.message ?? String(error));
2336
+ const known = lastKnown.get(agentId);
2337
+ return known === void 0 ? { ok: false } : {
2338
+ ok: true,
2339
+ settings: known
2340
+ };
2341
+ }
2342
+ }
2343
+ function authorizedRouteView(ctx, agent) {
2344
+ const header = agent.session?.header;
2345
+ const parentId = header?.origin === "subagent" ? header.parentSession : void 0;
2346
+ const parent = parentId === void 0 ? void 0 : ctx.get("agents")?.get(parentId);
2347
+ const parentHeader = parent?.session.requestHeader()?.config;
2348
+ const parentOptions = parent?.options;
2056
2349
  return {
2057
- ...withoutInheritedEffort,
2058
- provider: to.provider,
2059
- model: to.model
2350
+ requestHeader: agent.session?.requestHeader()?.config,
2351
+ events: agent.session?.events,
2352
+ options: agent.options,
2353
+ inherited: parentHeader ?? (typeof parentOptions?.provider === "string" && typeof parentOptions?.model === "string" ? {
2354
+ provider: parentOptions.provider,
2355
+ model: parentOptions.model
2356
+ } : void 0)
2060
2357
  };
2061
2358
  }
2062
2359
  function apply(ctx, config = defaultFallbacksConfig) {
@@ -2105,21 +2402,40 @@ function apply(ctx, config = defaultFallbacksConfig) {
2105
2402
  if (legacyKeys.length > 0) logger.warn("llm-fallbacks: legacy config keys detected (chains/roles.default/undeclared role refs); see docs/configuration.md migration table — %o", legacyKeys);
2106
2403
  let roleIds = new Map(entry.roles.list.map((role) => [role.id.trim(), role.id]));
2107
2404
  let hasChains = entry.rootChain.length > 0 || entry.roles.list.some((role) => (role.chain?.length ?? 0) > 0);
2108
- installSettingsSection(ctx, FALLBACKS_SETTINGS_NAMESPACE, Config, entry, {
2109
- setSource: (current) => {
2110
- source = current;
2111
- },
2112
- onChange: () => {
2113
- const current = source();
2114
- roleIds = new Map(current.roles.list.map((role) => [role.id.trim(), role.id]));
2115
- hasChains = current.rootChain.length > 0 || current.roles.list.some((role) => (role.chain?.length ?? 0) > 0);
2116
- reconcileFallbacksAdapter();
2117
- slotWinners.clear();
2118
- }
2405
+ ctx.inject(["settings"], (sctx) => {
2406
+ sctx.settings.installSection(ctx, FALLBACKS_SETTINGS_NAMESPACE, Config, entry, {
2407
+ setSource: (current) => {
2408
+ source = current;
2409
+ },
2410
+ onChange: () => {
2411
+ const current = source();
2412
+ roleIds = new Map(current.roles.list.map((role) => [role.id.trim(), role.id]));
2413
+ hasChains = current.rootChain.length > 0 || current.roles.list.some((role) => (role.chain?.length ?? 0) > 0);
2414
+ reconcileFallbacksAdapter();
2415
+ slotWinners.clear();
2416
+ }
2417
+ });
2119
2418
  });
2120
2419
  const bridge = { source: () => source() };
2420
+ const chainHeadMap = new Map();
2421
+ chainHeadStores.set(ctx, chainHeadMap);
2422
+ const blockedAttemptMap = new Map();
2423
+ blockedAttemptStores.set(ctx, blockedAttemptMap);
2121
2424
  try {
2122
- new FallbacksConfigGateway(ctx, bridge, seeds);
2425
+ new FallbacksConfigGateway(ctx, bridge, seeds, () => {
2426
+ try {
2427
+ return {
2428
+ policy: effectivePolicy({
2429
+ ok: false,
2430
+ present: false
2431
+ }, readSubagentSettings(ctx)),
2432
+ head: latestByAt(chainHeadMap),
2433
+ blockedAttempt: latestByAt(blockedAttemptMap)
2434
+ };
2435
+ } catch {
2436
+ return { policy: { state: "unprovable" } };
2437
+ }
2438
+ });
2123
2439
  } catch (error) {
2124
2440
  if (!(error instanceof Error) || !error.message.includes("has been registered")) throw error;
2125
2441
  ctx.logger("llm-fallbacks").debug("fallbacks gateway already registered — no gateway on this fiber (multi-fiber dedupe)");
@@ -2137,6 +2453,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2137
2453
  stateStores.set(ctx, states);
2138
2454
  const dispatchInjected = new Set();
2139
2455
  const slotWinners = new Map();
2456
+ const lastKnownPolicySettings = new Map();
2140
2457
  async function decide(agent, turn, step, current, reason, state) {
2141
2458
  const config = source();
2142
2459
  if (state !== void 0) {
@@ -2148,15 +2465,48 @@ function apply(ctx, config = defaultFallbacksConfig) {
2148
2465
  const { all, wildcard } = resolveChainViews(config.roles.list, rootTail, role, current.provider, current.model, logger.warn);
2149
2466
  if (all.length === 0) return null;
2150
2467
  const modelExists = hasWildcardEntry(config.roles.list, rootTail, role) ? await makeModelExists(ctx, [...new Set(all.map((candidate) => candidate.provider))]) : void 0;
2151
- const cooldown = { isSuppressed: (key) => state !== void 0 && states.isSuppressed(state, key) };
2468
+ const cooldown = { isSuppressed: (key) => state !== void 0 && states.isSuppressed(state, key, Date.now(), source().recovery ?? "timer") };
2152
2469
  const failed = { has: (key) => state !== void 0 && state.stepFailures.failed.has(key) };
2153
2470
  const surviving = selectCandidates(all, wildcard, createCandidateFilter({
2154
2471
  current,
2155
2472
  cooldown,
2156
2473
  failed
2157
2474
  }), modelExists);
2158
- const target = surviving[0];
2159
- if (target === void 0 || target.model === void 0) return null;
2475
+ const first = surviving[0];
2476
+ let target;
2477
+ if (first !== void 0 && first.model !== void 0) target = {
2478
+ provider: first.provider,
2479
+ model: first.model
2480
+ };
2481
+ if (agent.session?.header?.origin === "subagent" && target !== void 0) {
2482
+ const settingsRead = readGuardedPolicySettings(ctx, agent.id, lastKnownPolicySettings, logger.warn);
2483
+ const policy = settingsRead.ok ? effectivePolicy(readSessionPolicyEvent(agent.session.events), settingsRead.settings) : { state: "unprovable" };
2484
+ if (policy.state === "unprovable") {
2485
+ logger.warn("llm-fallbacks: agent \"%s\" subagent model-selection policy is on but unreadable — skipping fallback switch", agent.id);
2486
+ return null;
2487
+ }
2488
+ if (policy.state === "enabled") {
2489
+ const allowed = firstAllowedCandidate(resolvedRoutes(surviving), policy.allowedModels);
2490
+ if (allowed === void 0) {
2491
+ logger.warn("llm-fallbacks: agent \"%s\" fallback switch %s/%s blocked: resolved candidates are all outside the subagent allowlist — no switch", agent.id, target.provider, target.model);
2492
+ blockedAttemptMap.set(agent.id, {
2493
+ at: Date.now(),
2494
+ route: {
2495
+ provider: target.provider,
2496
+ model: target.model
2497
+ },
2498
+ reason
2499
+ });
2500
+ return null;
2501
+ }
2502
+ target = {
2503
+ provider: allowed.provider,
2504
+ model: allowed.model
2505
+ };
2506
+ }
2507
+ }
2508
+ if (target === void 0) return null;
2509
+ 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);
2160
2510
  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, {
2161
2511
  current,
2162
2512
  cooldown,
@@ -2178,13 +2528,22 @@ function apply(ctx, config = defaultFallbacksConfig) {
2178
2528
  function commit(state, pending, turn, step) {
2179
2529
  const config = source();
2180
2530
  const fromKey = selectorKey(pending.from.provider, pending.from.model);
2181
- const until = config.revertPolicy === "never" ? Number.POSITIVE_INFINITY : Date.now() + config.cooldownMs;
2531
+ 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;
2182
2532
  states.syncStep(state, turn, step);
2183
2533
  states.writePending(state, pending);
2184
2534
  states.suppress(state, fromKey, until);
2185
2535
  states.recordFailure(state, fromKey);
2186
2536
  states.recordSwitch(state);
2187
2537
  }
2538
+ function failHalfOpenProbe(agentId, current) {
2539
+ const key = selectorKey(current.provider, current.model);
2540
+ if ((source().recovery ?? "timer") !== "half-open") return;
2541
+ if (source().revertPolicy === "never") return;
2542
+ if (states.peek(agentId)?.recovery.isHalfOpen(key) !== true) return;
2543
+ const state = states.get(agentId);
2544
+ const n = state.recovery.recordFailure(key);
2545
+ states.suppress(state, key, Date.now() + escalatedCooldownMs(source().cooldownMs, n));
2546
+ }
2188
2547
  ctx.on("agent/request-error", async ({ agent, turn, step, provider, failure }, next) => {
2189
2548
  const config = source();
2190
2549
  if (!config.enabled || !config.triggerCodes.includes(failure.code)) return next();
@@ -2192,7 +2551,10 @@ function apply(ctx, config = defaultFallbacksConfig) {
2192
2551
  if (!current.model) return next();
2193
2552
  try {
2194
2553
  const pending = await decide(agent, turn, step, current, "trigger-code", states.peek(agent.id));
2195
- if (pending === null) return next();
2554
+ if (pending === null) {
2555
+ failHalfOpenProbe(agent.id, current);
2556
+ return next();
2557
+ }
2196
2558
  commit(states.get(agent.id), pending, turn, step);
2197
2559
  return { kind: "retry" };
2198
2560
  } catch (error) {
@@ -2230,21 +2592,54 @@ function apply(ctx, config = defaultFallbacksConfig) {
2230
2592
  if (config.enabled && hasChains && agent.session?.header?.origin === "subagent" && !dispatchInjected.has(agent.id)) {
2231
2593
  dispatchInjected.add(agent.id);
2232
2594
  try {
2233
- const role = await resolveRoleAtDispatch(agent, config.roles.rules, roleIds, {
2234
- automatchEnabled: config.roleAutoMatch ?? true,
2235
- automatch: (agent) => pickRoleByLlm(ctx, config.roles, agent, { warn: logger.warn }),
2236
- warn: logger.warn
2237
- });
2238
- if (role !== "inherit") {
2239
- const { all, wildcard } = resolveChainViews(config.roles.list, config.rootChain, role, seed.provider, seed.model, logger.warn);
2240
- const head = firstExactCandidate(all, wildcard);
2241
- if (head !== void 0 && head.model !== void 0 && !(head.provider === seed.provider && head.model === seed.model)) {
2242
- const to = {
2243
- provider: head.provider,
2244
- model: head.model
2245
- };
2246
- logger.info("llm-fallbacks: agent \"%s\" role-inject role=%s model=%s/%s", agent.id, role, head.provider, head.model);
2247
- return overrideConfig(seed, to);
2595
+ const settingsRead = readGuardedPolicySettings(ctx, agent.id, lastKnownPolicySettings, logger.warn);
2596
+ const policy = settingsRead.ok ? effectivePolicy(readSessionPolicyEvent(agent.session.events), settingsRead.settings) : { state: "unprovable" };
2597
+ if (policy.state === "unprovable") logger.warn("llm-fallbacks: agent \"%s\" subagent model-selection policy is on but unreadable — skipping role-inject", agent.id);
2598
+ else {
2599
+ const authorized = policy.state === "enabled" ? detectAuthorizedRoute(authorizedRouteView(ctx, agent)) : void 0;
2600
+ if (authorized !== void 0) {
2601
+ chainHeadMap.set(agent.id, {
2602
+ at: Date.now(),
2603
+ route: {
2604
+ provider: authorized.provider,
2605
+ model: authorized.model
2606
+ },
2607
+ source: "authorized"
2608
+ });
2609
+ logger.info("llm-fallbacks: agent \"%s\" authorized child route %s/%s is the chain head — skipping role-inject", agent.id, authorized.provider, authorized.model);
2610
+ } else {
2611
+ const role = await resolveRoleAtDispatch(agent, config.roles.rules, roleIds, {
2612
+ automatchEnabled: config.roleAutoMatch ?? true,
2613
+ automatch: (agent) => pickRoleByLlm(ctx, config.roles, agent, { warn: logger.warn }),
2614
+ warn: logger.warn
2615
+ });
2616
+ if (role !== "inherit") {
2617
+ const { all, wildcard } = resolveChainViews(config.roles.list, config.rootChain, role, seed.provider, seed.model, logger.warn);
2618
+ let to;
2619
+ if (policy.state === "enabled") {
2620
+ const routes = resolvedRoutes(all);
2621
+ to = firstAllowedCandidate(routes, policy.allowedModels);
2622
+ if (to === void 0 && routes.length > 0) logger.warn("llm-fallbacks: agent \"%s\" role-inject candidates are all outside the subagent allowlist — skipping inject (host seed stands)", agent.id);
2623
+ } else {
2624
+ const head = firstExactCandidate(all, wildcard);
2625
+ if (head !== void 0 && head.model !== void 0) to = {
2626
+ provider: head.provider,
2627
+ model: head.model
2628
+ };
2629
+ }
2630
+ if (to !== void 0 && !(to.provider === seed.provider && to.model === seed.model)) {
2631
+ if (policy.state === "enabled") chainHeadMap.set(agent.id, {
2632
+ at: Date.now(),
2633
+ route: {
2634
+ provider: to.provider,
2635
+ model: to.model
2636
+ },
2637
+ source: "injected"
2638
+ });
2639
+ logger.info("llm-fallbacks: agent \"%s\" role-inject role=%s model=%s/%s", agent.id, role, to.provider, to.model);
2640
+ return overrideConfig(seed, to);
2641
+ }
2642
+ }
2248
2643
  }
2249
2644
  }
2250
2645
  } catch (error) {
@@ -2262,7 +2657,10 @@ function apply(ctx, config = defaultFallbacksConfig) {
2262
2657
  commit(commitState, pending, turn, step);
2263
2658
  const appliedCap = states.applyPending(commitState, turn, step);
2264
2659
  if (appliedCap !== void 0) return overrideConfig(seed, appliedCap.to);
2265
- }
2660
+ } else failHalfOpenProbe(agent.id, {
2661
+ provider: seed.provider,
2662
+ model: seed.model
2663
+ });
2266
2664
  }
2267
2665
  return seed;
2268
2666
  });
@@ -2275,24 +2673,46 @@ function apply(ctx, config = defaultFallbacksConfig) {
2275
2673
  states.delete(agent.id);
2276
2674
  dispatchInjected.delete(agent.id);
2277
2675
  slotWinners.delete(agent.id);
2676
+ blockedAttemptMap.delete(agent.id);
2677
+ chainHeadMap.delete(agent.id);
2678
+ lastKnownPolicySettings.delete(agent.id);
2679
+ });
2680
+ ctx.on("session/event", (session, event) => {
2681
+ if (event.type !== "assistant/message") return;
2682
+ if ((source().recovery ?? "timer") !== "half-open") return;
2683
+ if (event.data.interrupted === true) return;
2684
+ const message = event.data.message;
2685
+ if (message.source.kind !== "model") return;
2686
+ const state = states.peek(session.id);
2687
+ if (state === void 0) return;
2688
+ states.observeSuccess(state, selectorKey(message.source.provider, message.source.model));
2278
2689
  });
2279
2690
  ctx.effect(() => () => {
2280
2691
  states.clear();
2281
2692
  dispatchInjected.clear();
2282
2693
  slotWinners.clear();
2694
+ blockedAttemptMap.clear();
2695
+ chainHeadMap.clear();
2696
+ lastKnownPolicySettings.clear();
2283
2697
  }, "llm-fallbacks: clear per-agent state");
2284
2698
  const fallbacksCommandController = {
2285
2699
  getSnapshot(agent) {
2286
2700
  const config = source();
2287
2701
  const role = resolveRole(agent, config.roles.rules, roleIds, logger.warn);
2288
2702
  const state = states.peek(agent.id);
2703
+ const recovery = config.recovery ?? "timer";
2704
+ if (state !== void 0 && recovery === "half-open") states.syncRecovery(state, Date.now(), recovery);
2289
2705
  return {
2290
2706
  origin: agent.session.header?.origin ?? "root",
2291
2707
  role,
2292
2708
  ...resolveChainForDiagnostic(config.roles.list, config.rootChain, role, logger.warn),
2293
2709
  slot: resolveSlotState(config, new Date(), config.tz ?? "Asia/Shanghai"),
2294
2710
  switches: recentFallbacksSwitches(agent.session.events, 5),
2295
- cooldown: state === void 0 ? [] : state.cooldown.snapshot()
2711
+ cooldown: state === void 0 ? [] : recovery === "half-open" ? [...state.cooldown.snapshot(), ...state.recovery.halfOpenEntries().map((entry) => ({
2712
+ key: entry.key,
2713
+ untilEpochMs: entry.untilEpochMs,
2714
+ halfOpen: true
2715
+ }))] : state.cooldown.snapshot()
2296
2716
  };
2297
2717
  },
2298
2718
  getConfig() {
@@ -2351,4 +2771,4 @@ function apply(ctx, config = defaultFallbacksConfig) {
2351
2771
  });
2352
2772
  }
2353
2773
  //#endregion
2354
- export { Config, FallbacksSeedManager, INHERIT_ROLE_ID, ROLE_ID_PATTERN, SelectorError, annotateCandidates, apply, countRetryEvents, createCandidateFilter, defaultFallbacksConfig, detectLegacyKeys, hasWildcardEntry, name, parseSelector, presetRoles, provide, resolveCandidate, resolveChain, resolveChainViews, resolveRole, selectCandidates, stateStore, validateFallbacksConfig };
2774
+ export { Config, FallbacksSeedManager, INHERIT_ROLE_ID, ROLE_ID_PATTERN, SelectorError, annotateCandidates, apply, blockedAttempts, chainHeads, countRetryEvents, createCandidateFilter, defaultFallbacksConfig, detectLegacyKeys, hasWildcardEntry, name, parseSelector, presetRoles, provide, resolveCandidate, resolveChain, resolveChainViews, resolveRole, selectCandidates, stateStore, validateFallbacksConfig };