dsh-llm-fallbacks 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -16,8 +16,9 @@
16
16
  * cooldown + failure bookkeeping, then return
17
17
  * `{ kind: 'retry' }` (own recovery, no `next()`).
18
18
  * - `agent/request` waterfall: apply a pending switch after `await next()`
19
- * (provider/model override, inherited `reasoningEffort` dropped — the
20
- * `installModelSelection` `withoutInheritedEffort` pattern); a
19
+ * (provider/model override; the inherited `reasoningEffort` follows the
20
+ * upstream routeChanged rule — dropped on a route change unless explicitly
21
+ * named, preserved on a same-route override; spec D3); a
21
22
  * root-origin `FallbacksChain/Auto` seed then overrides to the
22
23
  * effective chain's first exact head (select-is-primary, plan
23
24
  * fallbacks-virtual-chain Task 2); then the always-mode cap check (count
@@ -36,7 +37,7 @@ import { detectLegacyKeys, validateFallbacksConfig, type FallbacksConfig } from
36
37
  import { Config } from './schema.ts';
37
38
  import { resolveChain } from './chains.ts';
38
39
  import { resolveRole } from './roles.ts';
39
- import { FallbackStateStore } from './state.ts';
40
+ import { FallbackStateStore, type BlockedSwitchAttempt, type EffectiveChainHead } from './state.ts';
40
41
  import { type EffectiveRolesReadback, type SeedDeclaration, type SeedDeclareOutcome, type SeedRevertOutcome } from './seeds.ts';
41
42
  /** The plugin row id mounted by the profile bundle patch. */
42
43
  export declare const name = "llm-fallbacks";
@@ -82,7 +83,9 @@ export { Config };
82
83
  /** The plugin's composition config — the `fallbacks` settings schema (spec §4). */
83
84
  export type Config = FallbacksConfig;
84
85
  export type { FallbackSwitchReason, FallbacksSwitchEventData } from './events.ts';
85
- export type { AgentFallbackState, FallbackStateStore, PendingSwitch, StepFailures } from './state.ts';
86
+ export type { AgentFallbackState, BlockedSwitchAttempt, FallbackStateStore, PendingSwitch, StepFailures } from './state.ts';
87
+ /** @internal T5 test-seam types (mirrors `BlockedSwitchAttempt`; not public API). */
88
+ export type { ChainHeadSource, EffectiveChainHead } from './state.ts';
86
89
  export { resolveRole, type AgentLike, type Origin, } from './roles.ts';
87
90
  export { annotateCandidates, createCandidateFilter, hasWildcardEntry, resolveCandidate, resolveChain, resolveChainViews, selectCandidates, type AnnotatedCandidate, type CandidateFilterOptions, type CandidateSkipReason, type FailingModel, } from './chains.ts';
88
91
  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';
@@ -96,6 +99,23 @@ export { presetRoles } from './presets.ts';
96
99
  * request must not grow the store) without reaching into the closure.
97
100
  */
98
101
  export declare function stateStore(ctx: Context): FallbackStateStore | undefined;
102
+ /**
103
+ * @internal Test seam (mirrors `stateStore`): the per-agent blocked-switch
104
+ * attempts recorded by the T3 allowlist gate for the plugin applied to `ctx`
105
+ * (spec D1; in-memory only, issue #52). Not part of the plugin's public
106
+ * surface; lets tests (and the T5 gateway projection) read blocked attempts
107
+ * without reaching into the closure. `undefined` when no plugin is applied.
108
+ */
109
+ export declare function blockedAttempts(ctx: Context): ReadonlyMap<string, BlockedSwitchAttempt> | undefined;
110
+ /**
111
+ * @internal Test seam (mirrors `blockedAttempts`): the per-agent effective
112
+ * chain heads recorded by the T2 inject path (source `authorized` |
113
+ * `injected`) for the plugin applied to `ctx` (spec D4 / T2 M3). Not part
114
+ * of the plugin's public surface; lets tests and the T5 gateway projection
115
+ * read the head without reaching into the closure. `undefined` when no
116
+ * plugin is applied.
117
+ */
118
+ export declare function chainHeads(ctx: Context): ReadonlyMap<string, EffectiveChainHead> | undefined;
99
119
  /**
100
120
  * Count durable `llm/retry` events for the current (turn, step, provider) in
101
121
  * **always mode** (ADR-2; spec §2 clause 5). Normal-mode retries belong to
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";
@@ -695,6 +694,98 @@ function firstExactCandidate(all, wildcard) {
695
694
  for (let index = 0; index < all.length; index += 1) if (!wildcard[index]) return all[index];
696
695
  }
697
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
698
789
  //#region .build/host/cooldown.js
699
790
  var CooldownStore = class {
700
791
  entries = new Map();
@@ -839,8 +930,29 @@ var FallbackStateStore = class {
839
930
  }
840
931
  };
841
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
842
954
  //#region .build/host/gateway.js
843
- const FALLBACKS_SETTINGS_NAMESPACE = settingsNamespace("fallbacks");
955
+ const FALLBACKS_SETTINGS_NAMESPACE = "fallbacks";
844
956
  const CONFIG_KEYS = {
845
957
  enabled: true,
846
958
  triggerCodes: true,
@@ -874,10 +986,12 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
874
986
  bridge;
875
987
  seeds;
876
988
  settings;
877
- constructor(ctx, bridge, seeds) {
989
+ subagentPolicy;
990
+ constructor(ctx, bridge, seeds, subagentPolicy) {
878
991
  super(ctx, "fallbacks");
879
992
  this.bridge = bridge;
880
993
  this.seeds = seeds;
994
+ this.subagentPolicy = subagentPolicy;
881
995
  ctx.inject(["settings"], (sctx) => {
882
996
  this.settings = sctx.settings;
883
997
  return () => {
@@ -923,11 +1037,14 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
923
1037
  }
924
1038
  readResult() {
925
1039
  const source = this.bridge.source();
926
- return {
1040
+ const result = {
927
1041
  config: this.readConfig(source),
928
1042
  legacyKeys: detectLegacyKeys(source),
929
1043
  seeds: this.seeds.wireStatus(this.seedsIo())
930
1044
  };
1045
+ const projected = projectSubagentPolicy(this.subagentPolicy);
1046
+ if (projected !== void 0) result.subagentPolicy = projected;
1047
+ return result;
931
1048
  }
932
1049
  seedsIo() {
933
1050
  const settings = this.settings;
@@ -940,6 +1057,41 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
940
1057
  };
941
1058
  }
942
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
+ }
943
1095
  function normalizeRoles(value) {
944
1096
  if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
945
1097
  const roles = {};
@@ -2040,6 +2192,15 @@ var FallbacksChainAdapter = class extends LlmAdapter {
2040
2192
  name: model
2041
2193
  };
2042
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
+ }
2043
2204
  stream(options) {
2044
2205
  const config = this.readConfig();
2045
2206
  const now = new Date();
@@ -2101,6 +2262,19 @@ const stateStores = new WeakMap();
2101
2262
  function stateStore(ctx) {
2102
2263
  return stateStores.get(ctx);
2103
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
+ }
2104
2278
  async function makeModelExists(ctx, providers) {
2105
2279
  const llm = ctx.get("llm");
2106
2280
  if (llm === void 0 || typeof llm.listModels !== "function") return () => true;
@@ -2117,7 +2291,7 @@ async function makeModelExists(ctx, providers) {
2117
2291
  }
2118
2292
  function countRetryEvents(session, turn, step, provider) {
2119
2293
  let count = 0;
2120
- const events = session.events;
2294
+ const events = session.snapshotEvents();
2121
2295
  for (let index = events.length - 1; index >= 0; index -= 1) {
2122
2296
  const event = events[index];
2123
2297
  const data = event.data;
@@ -2137,12 +2311,49 @@ function slotWinnerKey(winner) {
2137
2311
  if (winner === "all-day") return "all-day";
2138
2312
  return winner.kind === "preset" ? `preset:${winner.preset}` : `custom:${winner.start}-${winner.end}`;
2139
2313
  }
2140
- function overrideConfig(seed, to) {
2141
- 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;
2142
2349
  return {
2143
- ...withoutInheritedEffort,
2144
- provider: to.provider,
2145
- model: to.model
2350
+ requestHeader: agent.session?.requestHeader()?.config,
2351
+ events: agent.session?.snapshotEvents(),
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)
2146
2357
  };
2147
2358
  }
2148
2359
  function apply(ctx, config = defaultFallbacksConfig) {
@@ -2191,21 +2402,40 @@ function apply(ctx, config = defaultFallbacksConfig) {
2191
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);
2192
2403
  let roleIds = new Map(entry.roles.list.map((role) => [role.id.trim(), role.id]));
2193
2404
  let hasChains = entry.rootChain.length > 0 || entry.roles.list.some((role) => (role.chain?.length ?? 0) > 0);
2194
- installSettingsSection(ctx, FALLBACKS_SETTINGS_NAMESPACE, Config, entry, {
2195
- setSource: (current) => {
2196
- source = current;
2197
- },
2198
- onChange: () => {
2199
- const current = source();
2200
- roleIds = new Map(current.roles.list.map((role) => [role.id.trim(), role.id]));
2201
- hasChains = current.rootChain.length > 0 || current.roles.list.some((role) => (role.chain?.length ?? 0) > 0);
2202
- reconcileFallbacksAdapter();
2203
- slotWinners.clear();
2204
- }
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
+ });
2205
2418
  });
2206
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);
2207
2424
  try {
2208
- 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
+ });
2209
2439
  } catch (error) {
2210
2440
  if (!(error instanceof Error) || !error.message.includes("has been registered")) throw error;
2211
2441
  ctx.logger("llm-fallbacks").debug("fallbacks gateway already registered — no gateway on this fiber (multi-fiber dedupe)");
@@ -2223,6 +2453,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2223
2453
  stateStores.set(ctx, states);
2224
2454
  const dispatchInjected = new Set();
2225
2455
  const slotWinners = new Map();
2456
+ const lastKnownPolicySettings = new Map();
2226
2457
  async function decide(agent, turn, step, current, reason, state) {
2227
2458
  const config = source();
2228
2459
  if (state !== void 0) {
@@ -2241,8 +2472,40 @@ function apply(ctx, config = defaultFallbacksConfig) {
2241
2472
  cooldown,
2242
2473
  failed
2243
2474
  }), modelExists);
2244
- const target = surviving[0];
2245
- 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.snapshotEvents()), 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;
2246
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);
2247
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, {
2248
2511
  current,
@@ -2329,21 +2592,54 @@ function apply(ctx, config = defaultFallbacksConfig) {
2329
2592
  if (config.enabled && hasChains && agent.session?.header?.origin === "subagent" && !dispatchInjected.has(agent.id)) {
2330
2593
  dispatchInjected.add(agent.id);
2331
2594
  try {
2332
- const role = await resolveRoleAtDispatch(agent, config.roles.rules, roleIds, {
2333
- automatchEnabled: config.roleAutoMatch ?? true,
2334
- automatch: (agent) => pickRoleByLlm(ctx, config.roles, agent, { warn: logger.warn }),
2335
- warn: logger.warn
2336
- });
2337
- if (role !== "inherit") {
2338
- const { all, wildcard } = resolveChainViews(config.roles.list, config.rootChain, role, seed.provider, seed.model, logger.warn);
2339
- const head = firstExactCandidate(all, wildcard);
2340
- if (head !== void 0 && head.model !== void 0 && !(head.provider === seed.provider && head.model === seed.model)) {
2341
- const to = {
2342
- provider: head.provider,
2343
- model: head.model
2344
- };
2345
- logger.info("llm-fallbacks: agent \"%s\" role-inject role=%s model=%s/%s", agent.id, role, head.provider, head.model);
2346
- return overrideConfig(seed, to);
2595
+ const settingsRead = readGuardedPolicySettings(ctx, agent.id, lastKnownPolicySettings, logger.warn);
2596
+ const policy = settingsRead.ok ? effectivePolicy(readSessionPolicyEvent(agent.session.snapshotEvents()), 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
+ }
2347
2643
  }
2348
2644
  }
2349
2645
  } catch (error) {
@@ -2377,6 +2673,9 @@ function apply(ctx, config = defaultFallbacksConfig) {
2377
2673
  states.delete(agent.id);
2378
2674
  dispatchInjected.delete(agent.id);
2379
2675
  slotWinners.delete(agent.id);
2676
+ blockedAttemptMap.delete(agent.id);
2677
+ chainHeadMap.delete(agent.id);
2678
+ lastKnownPolicySettings.delete(agent.id);
2380
2679
  });
2381
2680
  ctx.on("session/event", (session, event) => {
2382
2681
  if (event.type !== "assistant/message") return;
@@ -2392,6 +2691,9 @@ function apply(ctx, config = defaultFallbacksConfig) {
2392
2691
  states.clear();
2393
2692
  dispatchInjected.clear();
2394
2693
  slotWinners.clear();
2694
+ blockedAttemptMap.clear();
2695
+ chainHeadMap.clear();
2696
+ lastKnownPolicySettings.clear();
2395
2697
  }, "llm-fallbacks: clear per-agent state");
2396
2698
  const fallbacksCommandController = {
2397
2699
  getSnapshot(agent) {
@@ -2405,7 +2707,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2405
2707
  role,
2406
2708
  ...resolveChainForDiagnostic(config.roles.list, config.rootChain, role, logger.warn),
2407
2709
  slot: resolveSlotState(config, new Date(), config.tz ?? "Asia/Shanghai"),
2408
- switches: recentFallbacksSwitches(agent.session.events, 5),
2710
+ switches: recentFallbacksSwitches(agent.session.snapshotEvents(), 5),
2409
2711
  cooldown: state === void 0 ? [] : recovery === "half-open" ? [...state.cooldown.snapshot(), ...state.recovery.halfOpenEntries().map((entry) => ({
2410
2712
  key: entry.key,
2411
2713
  untilEpochMs: entry.untilEpochMs,
@@ -2469,4 +2771,4 @@ function apply(ctx, config = defaultFallbacksConfig) {
2469
2771
  });
2470
2772
  }
2471
2773
  //#endregion
2472
- 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 };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * RouteChanged effort rule for config overrides (plan dsh-012-subagent-routing
3
+ * T4; spec D3).
4
+ *
5
+ * Pure, host-free module: plain seed config plus a target route in, the
6
+ * overridden config out. The effort handling mirrors the upstream
7
+ * `resolveChildAgentOptions` rule (deepseek-harness
8
+ * `subagent/src/child-agent.ts:117`): a changed provider+model route drops the
9
+ * seed's route-owned `reasoningEffort` so the selected model resolves its own
10
+ * default, unless an effort is explicitly named for the override; an
11
+ * unchanged route keeps the seed effort. Policy-independent by design — the
12
+ * same rule applies on every override path whether the subagent routing
13
+ * policy is on or off (spec D3). Never throws.
14
+ *
15
+ * @module dsh-llm-fallbacks/override
16
+ */
17
+ import type { LlmCallConfig } from '@deepseek-ai/dsh-llm';
18
+ /** Reasoning-effort value carried by `LlmCallConfig.reasoningEffort`. */
19
+ export type LlmReasoningEffort = NonNullable<LlmCallConfig['reasoningEffort']>;
20
+ /**
21
+ * Override a request config with a target route under the upstream
22
+ * routeChanged effort rule (spec D3):
23
+ *
24
+ * - An explicitly named effort always survives — it wins over the seed effort
25
+ * on either route (upstream semantics: the request overrides the parent).
26
+ * - Same provider+model route → the seed `reasoningEffort` is preserved.
27
+ * - Route changed without an explicit effort → the seed effort is dropped
28
+ * (never carry a stale effort into a different provider/model route).
29
+ *
30
+ * @param seed - the caller's current request config.
31
+ * @param to - the effective target route.
32
+ * @param explicitEffort - an effort explicitly named for this override.
33
+ * @returns a fresh config routed to `to` with the effort rule applied; never
34
+ * mutates `seed`.
35
+ */
36
+ export declare function overrideConfigWithRouteRule(seed: LlmCallConfig, to: {
37
+ provider: string;
38
+ model: string;
39
+ }, explicitEffort?: LlmReasoningEffort): LlmCallConfig;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Route identity and allowlist intersection on resolved candidates (plan
3
+ * dsh-012-subagent-routing T2; spec D1 ∩ D2).
4
+ *
5
+ * Pure, host-free module: plain route data in, results out. Route identity is
6
+ * the EXACT provider string plus the EXACT model string — no aliasing, no
7
+ * case-folding, no catalog-id substitution (spec Terms). Intersections run on
8
+ * RESOLVED candidates (concrete provider+model after the chain candidate
9
+ * pipeline); this module never resolves chains or expands wildcards — the
10
+ * caller passes already-resolved candidates in.
11
+ *
12
+ * Fail-closed direction: an empty allowlist contains nothing and an empty
13
+ * intersection yields `undefined` — the caller must skip the
14
+ * plugin-originated route rather than fall back to an unproven one (spec D1).
15
+ * Never throws.
16
+ *
17
+ * @module dsh-llm-fallbacks/route-allowlist
18
+ */
19
+ import type { PolicyRoute } from './subagent-policy.ts';
20
+ /** One resolved chain candidate: a selector anchored to a concrete model id. */
21
+ export type ResolvedCandidate = {
22
+ readonly provider: string;
23
+ readonly model?: string;
24
+ };
25
+ /**
26
+ * Normalize resolved chain candidates into detached plain routes. Candidates
27
+ * without a concrete model id are dropped (they are not routes); every
28
+ * surviving entry is a fresh object, so the result never aliases the
29
+ * caller's candidate list.
30
+ */
31
+ export declare function resolvedRoutes(candidates: readonly ResolvedCandidate[]): PolicyRoute[];
32
+ /**
33
+ * Whether the route is on the allowlist under exact route identity: both the
34
+ * provider and the model strings must equal an allowed route exactly.
35
+ */
36
+ export declare function routeOnAllowlist(route: PolicyRoute, allowedModels: readonly PolicyRoute[]): boolean;
37
+ /**
38
+ * The first resolved candidate that is on the allowlist, preserving the
39
+ * candidate order (the chain's first in-allowlist entry wins), or `undefined`
40
+ * when the intersection is empty — the caller then skips the inject / switch
41
+ * and the host seed stands (spec D1 ∩ D2).
42
+ */
43
+ export declare function firstAllowedCandidate(resolved: readonly PolicyRoute[], allowedModels: readonly PolicyRoute[]): PolicyRoute | undefined;