dsh-llm-fallbacks 0.4.3 → 0.5.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.js CHANGED
@@ -34,6 +34,9 @@ function resolveWildcardEntry(failingModel, provider) {
34
34
  raw: `${provider}/${failingModel}`
35
35
  };
36
36
  }
37
+ function isOfficialAllDayId(id) {
38
+ return id === "deepseek-official/deepseek-flash" || id === "deepseek-official/deepseek-pro";
39
+ }
37
40
  const PRESETS = {
38
41
  "liang-peak": {
39
42
  windows: [{
@@ -220,7 +223,7 @@ function labelOf(row) {
220
223
  function isAllDayConforming(chain) {
221
224
  if (chain.length < 1) return false;
222
225
  const tail = chain[chain.length - 1];
223
- return tail === "deepseek-official/deepseek-v4-flash" || tail === "deepseek-official/deepseek-v4-pro";
226
+ return isOfficialAllDayId(tail);
224
227
  }
225
228
  function resolveEffectiveChain(config, now, tz) {
226
229
  const state = resolveSlotState(config, now, tz);
@@ -359,7 +362,7 @@ function validateFallbacksConfig(config, logger) {
359
362
  if ((role.chain ?? []).length === 0) logger.warn(`llm-fallbacks: role "${role.id}" has no model config — declare at least one chain entry, or use the built-in "inherit" rule target instead`);
360
363
  if (role.fallback !== void 0 && role.fallback !== "inherit-root" && role.fallback !== "none") logger.warn(`llm-fallbacks: role "${role.id}" has invalid fallback "${String(role.fallback)}" — expected "inherit-root" or "none"`);
361
364
  }
362
- if (config.rootChain.length > 0 && !isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: rootChain must end with exactly one official V4 model (deepseek-official/deepseek-v4-flash or deepseek-official/deepseek-v4-pro) — time-slot rows and the virtual picker row stay inert until the all-day chain tail conforms");
365
+ if (config.rootChain.length > 0 && !isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: rootChain must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro) — time-slot rows and the virtual picker row stay inert until the all-day chain tail conforms");
363
366
  for (const entry of config.rootChain) try {
364
367
  parseSelector(entry);
365
368
  } catch (error) {
@@ -665,11 +668,15 @@ function annotateCandidates(candidates, surviving, options) {
665
668
  }
666
669
  //#endregion
667
670
  //#region .build/host/roles.js
668
- function resolveRole(agent, rules, roleIds, warn = console.warn) {
671
+ function resolveRole(agent, rules, roleIds, warn = console.warn, servedRoute) {
669
672
  if ((agent.session?.header?.origin ?? "root") !== "subagent") return INHERIT_ROLE_ID;
673
+ const recordedRoute = {
674
+ provider: agent.options?.provider,
675
+ model: agent.options?.model
676
+ };
670
677
  for (const rule of rules) {
671
- if (rule.provider && rule.provider !== agent.options?.provider) continue;
672
- if (rule.model && rule.model !== agent.options?.model) continue;
678
+ const matchesPair = (pair) => (!rule.provider || rule.provider === pair.provider) && (!rule.model || rule.model === pair.model);
679
+ if (!matchesPair(recordedRoute) && !(servedRoute !== void 0 && matchesPair(servedRoute))) continue;
673
680
  const target = rule.role.trim();
674
681
  if (target === "inherit") return INHERIT_ROLE_ID;
675
682
  const declared = roleIds.get(target);
@@ -689,7 +696,7 @@ async function resolveRoleAtDispatch(agent, rules, roleIds, opts) {
689
696
  const declared = roleIds.get(preset);
690
697
  if (declared !== void 0) return declared;
691
698
  }
692
- const role = resolveRole(agent, rules, roleIds, opts.warn);
699
+ const role = resolveRole(agent, rules, roleIds, opts.warn, opts.servedRoute);
693
700
  if (role !== "inherit") return role;
694
701
  if (opts.automatchEnabled && opts.automatch !== void 0) {
695
702
  const picked = await opts.automatch(agent);
@@ -1160,7 +1167,7 @@ function validateConfigPatch(patch) {
1160
1167
  }
1161
1168
  if (key === "rootChain") {
1162
1169
  const chain = patch[key];
1163
- if (chain !== null && chain !== void 0 && (!Array.isArray(chain) || !isAllDayConforming(chain))) throw new Error("dsh-llm-fallbacks: rootChain must end with exactly one official V4 model (deepseek-official/deepseek-v4-flash or deepseek-official/deepseek-v4-pro)");
1170
+ if (chain !== null && chain !== void 0 && (!Array.isArray(chain) || !isAllDayConforming(chain))) throw new Error("dsh-llm-fallbacks: rootChain must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro)");
1164
1171
  }
1165
1172
  }
1166
1173
  Config(patch);
@@ -1598,6 +1605,7 @@ function registerFallbacksCommands(registry, controller, locale = "zh") {
1598
1605
  var FallbacksSeedManager = class {
1599
1606
  logger;
1600
1607
  registry = new Map();
1608
+ declareSeq = 0;
1601
1609
  constructor(logger) {
1602
1610
  this.logger = logger;
1603
1611
  }
@@ -1643,28 +1651,69 @@ var FallbacksSeedManager = class {
1643
1651
  const config = io.read();
1644
1652
  const currentList = roleRows(config);
1645
1653
  const currentRules = roleRules(config);
1646
- const newList = materialize(currentList, personaView(entries), personaView(this.registry), outcome.conflicts);
1654
+ const candidate = new Map(this.registry);
1655
+ if (entries.size === 0) candidate.delete(source);
1656
+ else candidate.set(source, {
1657
+ seq: this.declareSeq + 1,
1658
+ entries
1659
+ });
1660
+ const prior = new Map();
1661
+ const incoming = new Map();
1662
+ for (const id of entries.keys()) {
1663
+ const before = this.resolveDeclared(id)?.persona;
1664
+ const after = this.resolveDeclared(id, candidate)?.persona;
1665
+ if (before !== void 0) prior.set(id, before);
1666
+ if (after !== void 0) incoming.set(id, after);
1667
+ }
1668
+ const newList = materialize(currentList, incoming, prior, outcome.conflicts);
1647
1669
  for (const conflict of outcome.conflicts) this.logger.warn(`llm-fallbacks: seeds: persona-source conflict for seed id ${JSON.stringify(conflict.id)} — operator row persona kept (never overwritten)`);
1648
1670
  const computed = {
1649
1671
  list: newList,
1650
1672
  rules: currentRules
1651
1673
  };
1652
1674
  if (!deepEqual(newList, currentList)) await io.writeRoles(computed);
1653
- this.registry = entries;
1675
+ if (entries.size === 0) this.registry.delete(source);
1676
+ else this.registry.set(source, {
1677
+ seq: ++this.declareSeq,
1678
+ entries
1679
+ });
1654
1680
  return outcome;
1655
1681
  }
1682
+ resolveDeclared(id, registry = this.registry) {
1683
+ const bundled = registry.get("bundled")?.entries.get(id);
1684
+ if (bundled !== void 0) return {
1685
+ persona: bundled.persona,
1686
+ source: "bundled"
1687
+ };
1688
+ let winner;
1689
+ for (const [label, slice] of registry) {
1690
+ if (label === "bundled") continue;
1691
+ const entry = slice.entries.get(id);
1692
+ if (entry === void 0) continue;
1693
+ if (winner === void 0 || slice.seq > winner.seq) winner = {
1694
+ persona: entry.persona,
1695
+ source: label,
1696
+ seq: slice.seq
1697
+ };
1698
+ }
1699
+ if (winner === void 0) return void 0;
1700
+ return {
1701
+ persona: winner.persona,
1702
+ source: winner.source
1703
+ };
1704
+ }
1656
1705
  effectiveRoles(io) {
1657
1706
  return { roles: roleRows(io.read()).map((row) => {
1658
- const entry = this.registry.get(row.id.trim());
1659
- const seeded = entry !== void 0;
1707
+ const declared = this.resolveDeclared(row.id.trim());
1708
+ const seeded = declared !== void 0;
1660
1709
  const effective = {
1661
1710
  id: row.id,
1662
1711
  persona: row.persona,
1663
1712
  seeded,
1664
- personaOverridden: seeded && row.persona !== entry.persona,
1665
- source: seeded ? entry.set : "user"
1713
+ personaOverridden: seeded && row.persona !== declared.persona,
1714
+ source: seeded ? declared.source : "user"
1666
1715
  };
1667
- if (seeded) effective.seedPersona = entry.persona;
1716
+ if (seeded) effective.seedPersona = declared.persona;
1668
1717
  if (row.chain !== void 0) effective.chain = row.chain;
1669
1718
  if (row.fallback !== void 0) effective.fallback = row.fallback;
1670
1719
  return effective;
@@ -1673,20 +1722,20 @@ var FallbacksSeedManager = class {
1673
1722
  wireStatus(io) {
1674
1723
  const status = [];
1675
1724
  for (const row of roleRows(io.read())) {
1676
- const entry = this.registry.get(row.id.trim());
1677
- if (entry === void 0) continue;
1725
+ const declared = this.resolveDeclared(row.id.trim());
1726
+ if (declared === void 0) continue;
1678
1727
  status.push({
1679
1728
  id: row.id,
1680
- overridden: row.persona !== entry.persona,
1681
- source: entry.set
1729
+ overridden: row.persona !== declared.persona,
1730
+ source: declared.source
1682
1731
  });
1683
1732
  }
1684
1733
  return status;
1685
1734
  }
1686
1735
  async revert(id, io) {
1687
1736
  const seedId = id.trim();
1688
- const entry = this.registry.get(seedId);
1689
- if (entry === void 0) return {
1737
+ const declared = this.resolveDeclared(seedId);
1738
+ if (declared === void 0) return {
1690
1739
  reverted: false,
1691
1740
  reason: "not-seeded"
1692
1741
  };
@@ -1698,13 +1747,13 @@ var FallbacksSeedManager = class {
1698
1747
  reverted: false,
1699
1748
  reason: "row-absent"
1700
1749
  };
1701
- if (rows[index].persona === entry.persona) return {
1750
+ if (rows[index].persona === declared.persona) return {
1702
1751
  reverted: true,
1703
- persona: entry.persona
1752
+ persona: declared.persona
1704
1753
  };
1705
1754
  const nextList = rows.map((row, i) => i === index ? {
1706
1755
  ...row,
1707
- persona: entry.persona
1756
+ persona: declared.persona
1708
1757
  } : row);
1709
1758
  await io.writeRoles({
1710
1759
  list: nextList,
@@ -1712,7 +1761,7 @@ var FallbacksSeedManager = class {
1712
1761
  });
1713
1762
  return {
1714
1763
  reverted: true,
1715
- persona: entry.persona
1764
+ persona: declared.persona
1716
1765
  };
1717
1766
  }
1718
1767
  warnSkip(id, reason) {
@@ -1737,43 +1786,38 @@ function resolveSource(options, logger) {
1737
1786
  logger.warn(`llm-fallbacks: seeds: ignoring declare set name ${shown} — invalid (empty after trim, non-string, or a reserved label ${reserved}); provenance degrades to "external", the seeds still apply`);
1738
1787
  return "external";
1739
1788
  }
1740
- function personaView(entries) {
1741
- const personas = new Map();
1742
- for (const [id, entry] of entries) personas.set(id, entry.persona);
1743
- return personas;
1744
- }
1745
- function materialize(rows, registry, previous, conflicts) {
1789
+ function materialize(rows, incoming, prior, conflicts) {
1746
1790
  const next = [];
1747
1791
  for (const row of rows) {
1748
1792
  const seedId = row.id.trim();
1749
- const incoming = registry.get(seedId);
1750
- if (incoming === void 0) {
1793
+ const inc = incoming.get(seedId);
1794
+ if (inc === void 0) {
1751
1795
  next.push(row);
1752
1796
  continue;
1753
1797
  }
1754
- const prior = previous.get(seedId);
1755
- if (prior === void 0) {
1756
- if (row.persona !== incoming) conflicts.push({
1798
+ const before = prior.get(seedId);
1799
+ if (before === void 0) {
1800
+ if (row.persona !== inc) conflicts.push({
1757
1801
  id: seedId,
1758
1802
  kind: "persona-source"
1759
1803
  });
1760
1804
  next.push(row);
1761
1805
  continue;
1762
1806
  }
1763
- if (row.persona === prior) {
1807
+ if (row.persona === before && inc !== before) {
1764
1808
  next.push({
1765
1809
  ...row,
1766
- persona: incoming
1810
+ persona: inc
1767
1811
  });
1768
1812
  continue;
1769
1813
  }
1770
- if (row.persona !== incoming) conflicts.push({
1814
+ if (row.persona !== inc) conflicts.push({
1771
1815
  id: seedId,
1772
1816
  kind: "persona-source"
1773
1817
  });
1774
1818
  next.push(row);
1775
1819
  }
1776
- for (const [id, persona] of registry) if (!rows.some((row) => row.id.trim() === id)) next.push({
1820
+ for (const [id, persona] of incoming) if (!rows.some((row) => row.id.trim() === id)) next.push({
1777
1821
  id,
1778
1822
  persona
1779
1823
  });
@@ -2058,10 +2102,10 @@ function buildFallbacksTuiSection() {
2058
2102
  zh: "根降级链(JSON)",
2059
2103
  en: "Root chain (JSON)"
2060
2104
  },
2061
- hint: "JSON array of model selectors; the last entry must be an official V4 model.",
2105
+ hint: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro). Pro is a legal tail but not yet available in the catalog — the settings card shows it disabled.",
2062
2106
  hintDescriptions: {
2063
- zh: "模型选择器 JSON 数组;末项必须是官方 V4 模型。",
2064
- en: "JSON array of model selectors; the last entry must be an official V4 model."
2107
+ zh: "模型选择器 JSON 数组;末项必须是官方模型(deepseek-official/deepseek-flash 或 deepseek-official/deepseek-pro)。Pro 是合法链尾但目录暂未提供——设置卡中显示为禁用。",
2108
+ en: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro). Pro is a legal tail but not yet available in the catalog — the settings card shows it disabled."
2065
2109
  },
2066
2110
  group: "chain",
2067
2111
  kind: "text",
@@ -2227,6 +2271,37 @@ async function resolveHeadDisplayName(llm, head) {
2227
2271
  } catch {}
2228
2272
  return head.model;
2229
2273
  }
2274
+ function envelopeProvenance(replayState) {
2275
+ if (typeof replayState !== "object" || replayState === null) return void 0;
2276
+ const response = replayState.response;
2277
+ if (typeof response !== "object" || response === null) return void 0;
2278
+ const { provider, model } = response;
2279
+ if (typeof provider !== "string" || provider === "" || provider === "FallbacksChain") return void 0;
2280
+ if (typeof model !== "string" || model === "") return void 0;
2281
+ return {
2282
+ provider,
2283
+ model
2284
+ };
2285
+ }
2286
+ function restoreEnvelopeProvenance(messages) {
2287
+ let changed = false;
2288
+ const restored = messages.map((message) => {
2289
+ const source = message.source;
2290
+ if (message.role !== "assistant" || source.kind !== "model") return message;
2291
+ if (source.provider !== "FallbacksChain" || source.model !== "Auto") return message;
2292
+ const provenance = envelopeProvenance(source.replayState);
2293
+ if (provenance === void 0) return message;
2294
+ changed = true;
2295
+ return {
2296
+ ...message,
2297
+ source: {
2298
+ ...source,
2299
+ ...provenance
2300
+ }
2301
+ };
2302
+ });
2303
+ return changed ? restored : messages;
2304
+ }
2230
2305
  var FallbacksChainAdapter = class extends LlmAdapter {
2231
2306
  readConfig;
2232
2307
  getLlm;
@@ -2274,6 +2349,15 @@ var FallbacksChainAdapter = class extends LlmAdapter {
2274
2349
  name: model
2275
2350
  };
2276
2351
  }
2352
+ providerRetryPolicy(_provider) {
2353
+ try {
2354
+ const head = effectiveHeadOf(this.readConfig(), new Date());
2355
+ if (head === void 0 || head.provider === "FallbacksChain") return void 0;
2356
+ return this.getLlm()?.providerRetryPolicy(head.provider);
2357
+ } catch {
2358
+ return;
2359
+ }
2360
+ }
2277
2361
  imageRequestPricing(_provider, _model) {
2278
2362
  try {
2279
2363
  const head = effectiveHeadOf(this.readConfig(), new Date());
@@ -2294,7 +2378,8 @@ var FallbacksChainAdapter = class extends LlmAdapter {
2294
2378
  return llm.stream({
2295
2379
  ...options,
2296
2380
  provider: head.provider,
2297
- model: head.model
2381
+ model: head.model,
2382
+ messages: restoreEnvelopeProvenance(options.messages)
2298
2383
  });
2299
2384
  }
2300
2385
  };
@@ -2409,11 +2494,29 @@ function countRetryEvents(session, turn, step, provider) {
2409
2494
  }
2410
2495
  return count;
2411
2496
  }
2412
- function currentModel(agent, provider) {
2413
- return {
2497
+ function anchorServedRoute(config, route, now) {
2498
+ if (route.provider !== "FallbacksChain" || route.model !== "Auto") return route;
2499
+ const head = effectiveHeadOf(config, now);
2500
+ return head === void 0 ? route : {
2501
+ provider: head.provider,
2502
+ model: head.model
2503
+ };
2504
+ }
2505
+ function servedRouteFor(agent, config) {
2506
+ const provider = agent.options?.provider;
2507
+ const model = agent.options?.model;
2508
+ if (provider === void 0 || model === void 0) return void 0;
2509
+ const anchored = anchorServedRoute(config, {
2510
+ provider,
2511
+ model
2512
+ }, new Date());
2513
+ return anchored.provider === provider && anchored.model === model ? void 0 : anchored;
2514
+ }
2515
+ function currentModel(agent, provider, config, now) {
2516
+ return anchorServedRoute(config, {
2414
2517
  provider,
2415
2518
  model: agent.session.requestHeader()?.config.model ?? agent.options.model ?? ""
2416
- };
2519
+ }, now);
2417
2520
  }
2418
2521
  function slotWinnerKey(winner) {
2419
2522
  if (winner === "all-day") return "all-day";
@@ -2570,7 +2673,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2570
2673
  states.syncStep(state, turn, step);
2571
2674
  if (state.stepFailures.switchCount >= config.maxSwitchesPerStep) return null;
2572
2675
  }
2573
- const role = resolveRole(agent, config.roles.rules, roleIds, logger.warn);
2676
+ const role = resolveRole(agent, config.roles.rules, roleIds, logger.warn, servedRouteFor(agent, config));
2574
2677
  const rootTail = agent.session?.header?.origin === "subagent" ? config.rootChain : resolveEffectiveChain(config, new Date(), config.tz ?? "Asia/Shanghai");
2575
2678
  const { all, wildcard } = resolveChainViews(config.roles.list, rootTail, role, current.provider, current.model, logger.warn);
2576
2679
  if (all.length === 0) return null;
@@ -2668,7 +2771,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2668
2771
  ctx.on("agent/request-error", async ({ agent, turn, step, provider, failure }, next) => {
2669
2772
  const config = source();
2670
2773
  if (!config.enabled || !config.triggerCodes.includes(failure.code)) return next();
2671
- const current = currentModel(agent, provider);
2774
+ const current = currentModel(agent, provider, config, new Date());
2672
2775
  if (!current.model) return next();
2673
2776
  try {
2674
2777
  const pending = await decide(agent, turn, step, current, "trigger-code", states.peek(agent.id), failure.code);
@@ -2703,17 +2806,6 @@ function apply(ctx, config = defaultFallbacksConfig) {
2703
2806
  label: slot.label
2704
2807
  });
2705
2808
  }
2706
- if (config.enabled && seed.provider === "FallbacksChain" && seed.model === "Auto" && agent.session?.header?.origin !== "subagent") {
2707
- if (!isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: FallbacksChain/Auto selected but the all-day rootChain is not conforming (exactly one official V4 model) — no primary override");
2708
- else {
2709
- const head = firstDispatchableExactHead(resolveEffectiveChain(config, new Date(), config.tz ?? "Asia/Shanghai"));
2710
- if (head === void 0) logger.warn("llm-fallbacks: FallbacksChain/Auto selected but the effective chain has no exact head (empty, wildcard-only, or self-route) — no primary override");
2711
- else {
2712
- logger.info("llm-fallbacks: FallbacksChain/Auto selection overrides to the effective head %s/%s", head.provider, head.model);
2713
- return overrideConfig(seed, head);
2714
- }
2715
- }
2716
- }
2717
2809
  if (config.enabled && hasChains && agent.session?.header?.origin === "subagent" && !dispatchInjected.has(agent.id)) {
2718
2810
  dispatchInjected.add(agent.id);
2719
2811
  try {
@@ -2736,7 +2828,8 @@ function apply(ctx, config = defaultFallbacksConfig) {
2736
2828
  const role = await resolveRoleAtDispatch(agent, config.roles.rules, roleIds, {
2737
2829
  automatchEnabled: config.roleAutoMatch ?? true,
2738
2830
  automatch: (agent) => pickRoleByLlm(ctx, config.roles, agent, { warn: logger.warn }),
2739
- warn: logger.warn
2831
+ warn: logger.warn,
2832
+ servedRoute: servedRouteFor(agent, config)
2740
2833
  });
2741
2834
  if (role !== "inherit") {
2742
2835
  const { all, wildcard } = resolveChainViews(config.roles.list, config.rootChain, role, seed.provider, seed.model, logger.warn);
@@ -2781,19 +2874,17 @@ function apply(ctx, config = defaultFallbacksConfig) {
2781
2874
  }
2782
2875
  if (hasChains && config.enabled && config.alwaysModeRetryCap > 0 && countRetryEvents(agent.session, turn, step, seed.provider) >= config.alwaysModeRetryCap) {
2783
2876
  const decisionState = states.peek(agent.id);
2784
- const pending = await decide(agent, turn, step, {
2877
+ const capRoute = anchorServedRoute(config, {
2785
2878
  provider: seed.provider,
2786
2879
  model: seed.model
2787
- }, "always-cap", decisionState);
2880
+ }, new Date());
2881
+ const pending = await decide(agent, turn, step, capRoute, "always-cap", decisionState);
2788
2882
  if (pending !== null) {
2789
2883
  const commitState = states.get(agent.id);
2790
2884
  commit(commitState, pending, turn, step);
2791
2885
  const appliedCap = states.applyPending(commitState, turn, step);
2792
2886
  if (appliedCap !== void 0) return overrideConfig(seed, appliedCap.to);
2793
- } else failHalfOpenProbe(agent.id, {
2794
- provider: seed.provider,
2795
- model: seed.model
2796
- });
2887
+ } else failHalfOpenProbe(agent.id, capRoute);
2797
2888
  }
2798
2889
  return seed;
2799
2890
  });
@@ -2813,13 +2904,18 @@ function apply(ctx, config = defaultFallbacksConfig) {
2813
2904
  });
2814
2905
  ctx.on("session/event", (session, event) => {
2815
2906
  if (event.type !== "assistant/message") return;
2816
- if ((source().recovery ?? "timer") !== "half-open") return;
2907
+ const config = source();
2908
+ if ((config.recovery ?? "timer") !== "half-open") return;
2817
2909
  if (event.data.interrupted === true) return;
2818
2910
  const message = event.data.message;
2819
2911
  if (message.source.kind !== "model") return;
2820
2912
  const state = states.peek(session.id);
2821
2913
  if (state === void 0) return;
2822
- states.observeSuccess(state, selectorKey(message.source.provider, message.source.model));
2914
+ const observed = anchorServedRoute(config, {
2915
+ provider: message.source.provider,
2916
+ model: message.source.model
2917
+ }, new Date());
2918
+ states.observeSuccess(state, selectorKey(observed.provider, observed.model));
2823
2919
  });
2824
2920
  ctx.effect(() => () => {
2825
2921
  states.clear();
@@ -2833,7 +2929,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2833
2929
  const fallbacksCommandController = {
2834
2930
  getSnapshot(agent) {
2835
2931
  const config = source();
2836
- const role = resolveRole(agent, config.roles.rules, roleIds, logger.warn);
2932
+ const role = resolveRole(agent, config.roles.rules, roleIds, logger.warn, servedRouteFor(agent, config));
2837
2933
  const state = states.peek(agent.id);
2838
2934
  const recovery = config.recovery ?? "timer";
2839
2935
  if (state !== void 0 && recovery === "half-open") states.syncRecovery(state, Date.now(), recovery);
@@ -51,6 +51,17 @@ export interface ResolveRoleAtDispatchOptions {
51
51
  automatch?: (agent: AgentLike) => Promise<string | null>;
52
52
  /** Warning sink — the decision path injects the plugin logger. */
53
53
  warn: (message: string) => void;
54
+ /**
55
+ * The route the agent's request was actually SERVED by, when that differs
56
+ * from the pair recorded on the agent (the virtual `FallbacksChain/Auto`
57
+ * row). Rule matching accepts either, so a rule keyed on the real chain head
58
+ * keeps matching a child whose recorded route is the virtual pair (QC1 I-1).
59
+ * `undefined` for every real route.
60
+ */
61
+ servedRoute?: {
62
+ provider: string;
63
+ model: string;
64
+ };
54
65
  }
55
66
  /**
56
67
  * Resolve the dispatch-time role for an agent across three ordered stages
package/dist/roles.d.ts CHANGED
@@ -82,4 +82,7 @@ export interface AgentLike {
82
82
  * decision path injects the cordis `logger.warn` so every plugin warning
83
83
  * flows through the plugin log namespace (qc2 F-002 / qc3 S-3).
84
84
  */
85
- export declare function resolveRole(agent: AgentLike, rules: FallbacksRoleRule[], roleIds: ReadonlyMap<string, string>, warn?: (message: string) => void): string;
85
+ export declare function resolveRole(agent: AgentLike, rules: FallbacksRoleRule[], roleIds: ReadonlyMap<string, string>, warn?: (message: string) => void, servedRoute?: {
86
+ provider: string;
87
+ model: string;
88
+ }): string;
package/dist/seeds.d.ts CHANGED
@@ -11,20 +11,34 @@
11
11
  * State model (spec §9.2) — two stores, strictly separated:
12
12
  * 1. operator config rows (persisted; a seeded role is a plain
13
13
  * `roles.list` row `{ id, persona }`), and
14
- * 2. an in-memory per-apply seed registry (`Map<id, { persona, set }>` —
15
- * the declared default persona plus its provenance label), declare =
16
- * replacement (the batch is the companion's full current set).
14
+ * 2. an in-memory per-apply seed registry keyed by PRODUCER LABEL
15
+ * (`bundled` | set name | `external`), each slice holding that
16
+ * producer's current id → `{ persona, set }` declarations. Declare =
17
+ * replacement PER PRODUCER: a declare replaces only its own slice, so
18
+ * the plugin's bundled presets stay `bundled` even when a companion
19
+ * merge-preserves them into its own batch (plan
20
+ * seeds-source-and-persona-width, Decisions #1).
17
21
  * `seeded` / `personaOverridden` / `source` are DERIVED at read time, never
18
22
  * stored — a config round-trip cannot orphan an override (AC-3), and
19
23
  * provenance rides the in-memory registry only, so it can never churn a
20
24
  * settings write.
21
25
  *
26
+ * Resolution precedence (Decisions #2): `bundled` wins for any id the
27
+ * plugin's own preset self-declare currently declares; otherwise the most
28
+ * recent non-bundled producer declaring the id wins (its set name, or
29
+ * `external` when unnamed); no live declaration ⇒ `user`. Unnamed producers
30
+ * share the `external` slice (Decisions #3); `{ set }` is the remedy.
31
+ *
22
32
  * Materialization (spec §9.2): append `{ id, persona }` (two keys only,
23
33
  * R4) / attach row untouched / at-default tracking / override preserved +
24
- * `'persona-source'` conflict / omitted id drops from the registry while
25
- * the row stays (R2). No delta → no settings write (idempotent, AC-1).
26
- * Compute → write → commit registry: a failed write throws and leaves the
27
- * registry unchanged (retry-safe).
34
+ * `'persona-source'` conflict / omitted id drops from the declaring
35
+ * producer's slice while the row stays (R2). Tracking is driven by the
36
+ * id's RESOLVED effective default — `prior` = resolved before the
37
+ * declare, `incoming` = resolved after the candidate slice commit — so a
38
+ * producer that is not the resolution winner for an id it declares never
39
+ * rewrites the row (F-001). No delta → no settings write (idempotent,
40
+ * AC-1). Compute → write → commit registry: a failed write throws and
41
+ * leaves the registry unchanged (retry-safe).
28
42
  *
29
43
  * @module dsh-llm-fallbacks/seeds
30
44
  */
@@ -151,13 +165,22 @@ export interface SeedsIo {
151
165
  */
152
166
  export declare class FallbacksSeedManager {
153
167
  private readonly logger;
154
- /** Per-apply declaration registry: declared default persona + provenance label. */
168
+ /**
169
+ * Per-producer declaration registry: producer label (`bundled` | set
170
+ * name | `external`) → that producer's current slice. A declare replaces
171
+ * only its own slice; an empty batch drops the slice.
172
+ */
155
173
  private registry;
174
+ /** Monotonic declare counter — "most recent producer" for resolution precedence. */
175
+ private declareSeq;
156
176
  constructor(logger: FallbacksConfigLogger);
157
177
  /**
158
- * Declare seeds with replacement semantics — the batch is the
159
- * companion's FULL current declaration set; ids omitted from the batch
160
- * drop out of the registry while their rows remain (R2).
178
+ * Declare seeds with replacement semantics PER PRODUCER — the batch is
179
+ * the producer's FULL current declaration set; ids omitted from the
180
+ * batch drop out of that producer's slice while their rows remain (R2).
181
+ * Other producers' slices are untouched, so a companion merge-preserving
182
+ * the bundled presets into its own batch never re-labels them (Decisions
183
+ * #1).
161
184
  *
162
185
  * `options` labels the whole batch's provenance (spec §2), resolved once
163
186
  * per call: an invalid set name warns once and degrades to the unnamed
@@ -165,27 +188,46 @@ export declare class FallbacksSeedManager {
165
188
  *
166
189
  * Per-id validation AS DECLARED (spec §9.3): non-string / pattern miss /
167
190
  * reserved `inherit` / duplicate-in-batch → skip + warn; valid siblings
168
- * still apply (AC-5). Materializes per spec §9.2, writes only when the
169
- * computed `{ list, rules }` differs from the current composed roles
170
- * (idempotent, AC-1), and commits the registry only after a successful
171
- * write (compute → write → commit; retry-safe).
191
+ * still apply (AC-5). Materializes per spec §9.2 against the id's
192
+ * RESOLVED effective default before/after the candidate slice commit
193
+ * (at-default tracking follows the resolution winner — a producer that
194
+ * is not the winner for an id it declares never rewrites the row),
195
+ * writes only when the computed `{ list, rules }` differs from the
196
+ * current composed roles (idempotent, AC-1), and commits the slice only
197
+ * after a successful write (compute → write → commit; retry-safe). An
198
+ * empty batch drops the producer's slice.
172
199
  */
173
200
  declare(seeds: readonly SeedDeclaration[], io: SeedsIo, options?: SeedsInternalDeclareOptions): Promise<SeedDeclareOutcome>;
201
+ /**
202
+ * Resolve one id's live declaration across producer slices (spec §2,
203
+ * Decisions #2): the bundled preset self-declare wins for any id it
204
+ * currently declares; otherwise the most recent non-bundled producer
205
+ * declaring the id wins (its set name, or `external` when unnamed); no
206
+ * live declaration ⇒ `undefined` (the row reads `user`). Single place —
207
+ * `effectiveRoles`, `wireStatus`, and `revert` can never disagree. The
208
+ * optional `registry` argument resolves against a CANDIDATE registry
209
+ * (the declaring slice already replaced) — the same precedence, used by
210
+ * `declare` to compute the post-commit effective default for tracking.
211
+ */
212
+ private resolveDeclared;
174
213
  /**
175
214
  * Readback (b) — sync, derived: every config row annotated with
176
215
  * `seeded` / `personaOverridden` / `seedPersona` (trimmed row-id
177
216
  * membership in the live declaration set; persona inequality) plus the
178
- * row's provenance `source` (spec §2): the declaring set's label, or
179
- * `user` when no live declaration covers the row. Nothing
180
- * override-shaped is stored, so a config round-trip cannot orphan state.
217
+ * row's provenance `source` (spec §2): the winning producer's label per
218
+ * `resolveDeclared`, or `user` when no live declaration covers the row.
219
+ * Nothing override-shaped is stored, so a config round-trip cannot
220
+ * orphan state.
181
221
  */
182
222
  effectiveRoles(io: SeedsIo): EffectiveRolesReadback;
183
223
  /** Card badge state (spec §9.4): seeded rows, with the override flag and the provenance label. */
184
224
  wireStatus(io: SeedsIo): SeedsWireStatus[];
185
225
  /**
186
- * Revert one id to the CURRENT declared seed default (AC-3). Writes
187
- * persona only — the row is otherwise copied verbatim (R4). Ids absent
188
- * from the registry (`not-seeded`) or with a deleted row (`row-absent`)
226
+ * Revert one id to the CURRENT declared seed default (AC-3) — resolved
227
+ * through the same precedence as the readbacks, so a preserved bundled id
228
+ * reverts to the bundled persona, never a companion's copy. Writes
229
+ * persona only — the row is otherwise copied verbatim (R4). Ids with no
230
+ * live declaration (`not-seeded`) or with a deleted row (`row-absent`)
189
231
  * return a non-reverted outcome without throwing; a failed settings
190
232
  * write propagates loudly (spec §9.1).
191
233
  */