dsh-llm-fallbacks 0.4.2 → 0.4.3

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
@@ -618,18 +618,25 @@ function hasWildcardEntry(roles, rootChain, role) {
618
618
  } catch {}
619
619
  return false;
620
620
  }
621
+ function fitsContextWindow(candidate, minContextWindow, contextWindowOf) {
622
+ if (minContextWindow === void 0 || contextWindowOf === void 0) return true;
623
+ if (candidate.model === void 0) return true;
624
+ const candidateWindow = contextWindowOf(candidate.provider, candidate.model);
625
+ return candidateWindow === void 0 || candidateWindow > minContextWindow;
626
+ }
621
627
  function createCandidateFilter(options) {
622
- const { current, cooldown, failed, modelExists } = options;
628
+ const { current, cooldown, failed, modelExists, minContextWindow, contextWindowOf } = options;
623
629
  return (candidate) => {
624
630
  if (candidate.provider === current.provider && candidate.model === current.model) return false;
625
631
  if (cooldown.isSuppressed(selectorKey(candidate.provider, candidate.model))) return false;
626
632
  if (failed.has(selectorKey(candidate.provider, candidate.model))) return false;
633
+ if (!fitsContextWindow(candidate, minContextWindow, contextWindowOf)) return false;
627
634
  if (modelExists && candidate.model !== void 0 && !modelExists(candidate.provider, candidate.model)) return false;
628
635
  return true;
629
636
  };
630
637
  }
631
638
  function annotateCandidates(candidates, surviving, options) {
632
- const { current, cooldown, failed } = options;
639
+ const { current, cooldown, failed, minContextWindow, contextWindowOf } = options;
633
640
  const usable = new Set(surviving.map((candidate) => selectorKey(candidate.provider, candidate.model)));
634
641
  return candidates.map((candidate) => {
635
642
  if (candidate.provider === current.provider && candidate.model === current.model) return {
@@ -646,6 +653,10 @@ function annotateCandidates(candidates, surviving, options) {
646
653
  candidate,
647
654
  skip: "step-failed"
648
655
  };
656
+ if (!fitsContextWindow(candidate, minContextWindow, contextWindowOf)) return {
657
+ candidate,
658
+ skip: "context-window"
659
+ };
649
660
  return {
650
661
  candidate,
651
662
  skip: "missing-id"
@@ -954,6 +965,7 @@ function overrideConfigWithRouteRule(seed, to, explicitEffort) {
954
965
  //#endregion
955
966
  //#region .build/host/gateway.js
956
967
  const FALLBACKS_SETTINGS_NAMESPACE = "fallbacks";
968
+ const SUBAGENT_ROLES_MAX_IDS = 256;
957
969
  const CONFIG_KEYS = {
958
970
  enabled: true,
959
971
  triggerCodes: true,
@@ -988,11 +1000,13 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
988
1000
  seeds;
989
1001
  settings;
990
1002
  subagentPolicy;
991
- constructor(ctx, bridge, seeds, subagentPolicy) {
1003
+ subagentRolesSnapshot;
1004
+ constructor(ctx, bridge, seeds, subagentPolicy, subagentRoles) {
992
1005
  super(ctx, "fallbacks");
993
1006
  this.bridge = bridge;
994
1007
  this.seeds = seeds;
995
1008
  this.subagentPolicy = subagentPolicy;
1009
+ this.subagentRolesSnapshot = subagentRoles;
996
1010
  ctx.inject(["settings"], (sctx) => {
997
1011
  this.settings = sctx.settings;
998
1012
  return () => {
@@ -1027,6 +1041,23 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
1027
1041
  outcome
1028
1042
  };
1029
1043
  }
1044
+ subagentRoles(ids) {
1045
+ if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) throw new TypeError("dsh-llm-fallbacks: subagent-roles ids must be an array of strings");
1046
+ if (ids.length > SUBAGENT_ROLES_MAX_IDS) throw new TypeError(`dsh-llm-fallbacks: subagent-roles ids batch exceeds ${SUBAGENT_ROLES_MAX_IDS} entries`);
1047
+ if (this.subagentRolesSnapshot === void 0) return {};
1048
+ let records;
1049
+ try {
1050
+ records = this.subagentRolesSnapshot();
1051
+ } catch {
1052
+ return {};
1053
+ }
1054
+ const projected = new Map();
1055
+ for (const id of ids) {
1056
+ const record = records.get(id);
1057
+ if (record !== void 0) projected.set(id, projectSubagentRoleRecord(record));
1058
+ }
1059
+ return Object.fromEntries(projected);
1060
+ }
1030
1061
  readConfig(source = this.bridge.source()) {
1031
1062
  const wire = {};
1032
1063
  for (const key of Object.keys(CONFIG_KEYS)) {
@@ -1093,6 +1124,17 @@ function projectSubagentPolicy(snapshot) {
1093
1124
  };
1094
1125
  return wire;
1095
1126
  }
1127
+ function projectSubagentRoleRecord(record) {
1128
+ const wire = {
1129
+ role: record.role,
1130
+ at: record.at
1131
+ };
1132
+ if (record.model !== void 0) wire.model = {
1133
+ provider: record.model.provider,
1134
+ model: record.model.model
1135
+ };
1136
+ return wire;
1137
+ }
1096
1138
  function normalizeRoles(value) {
1097
1139
  if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
1098
1140
  const roles = {};
@@ -1207,6 +1249,21 @@ function fallbacksTypertContribution() {
1207
1249
  codec: { mode: "src-json" }
1208
1250
  }],
1209
1251
  result: { mode: "src-json" }
1252
+ },
1253
+ {
1254
+ id: "dsh-llm-fallbacks#fallbacks/subagent-roles",
1255
+ service: "fallbacks",
1256
+ namespace: "fallbacks",
1257
+ method: "subagent-roles",
1258
+ implementation: "subagentRoles",
1259
+ invocation: { kind: "direct" },
1260
+ parameters: [{
1261
+ name: "ids",
1262
+ wire: "ids",
1263
+ source: "json",
1264
+ codec: { mode: "src-json" }
1265
+ }],
1266
+ result: { mode: "src-json" }
1210
1267
  }
1211
1268
  ]
1212
1269
  };
@@ -1544,13 +1601,14 @@ var FallbacksSeedManager = class {
1544
1601
  constructor(logger) {
1545
1602
  this.logger = logger;
1546
1603
  }
1547
- async declare(seeds, io) {
1604
+ async declare(seeds, io, options) {
1548
1605
  const outcome = {
1549
1606
  applied: [],
1550
1607
  skipped: [],
1551
1608
  conflicts: []
1552
1609
  };
1553
- const registry = new Map();
1610
+ const source = resolveSource(options, this.logger);
1611
+ const entries = new Map();
1554
1612
  for (const seed of seeds) {
1555
1613
  if (typeof seed.id !== "string" || !ROLE_ID_PATTERN.test(seed.id)) {
1556
1614
  outcome.skipped.push({
@@ -1568,7 +1626,7 @@ var FallbacksSeedManager = class {
1568
1626
  this.warnSkip(seed.id, "reserved-id");
1569
1627
  continue;
1570
1628
  }
1571
- if (registry.has(seed.id)) {
1629
+ if (entries.has(seed.id)) {
1572
1630
  outcome.skipped.push({
1573
1631
  id: seed.id,
1574
1632
  reason: "duplicate-in-batch"
@@ -1576,33 +1634,37 @@ var FallbacksSeedManager = class {
1576
1634
  this.warnSkip(seed.id, "duplicate-in-batch");
1577
1635
  continue;
1578
1636
  }
1579
- registry.set(seed.id, seed.persona);
1637
+ entries.set(seed.id, {
1638
+ persona: seed.persona,
1639
+ set: source
1640
+ });
1580
1641
  outcome.applied.push(seed.id);
1581
1642
  }
1582
1643
  const config = io.read();
1583
1644
  const currentList = roleRows(config);
1584
1645
  const currentRules = roleRules(config);
1585
- const newList = materialize(currentList, registry, this.registry, outcome.conflicts);
1646
+ const newList = materialize(currentList, personaView(entries), personaView(this.registry), outcome.conflicts);
1586
1647
  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)`);
1587
1648
  const computed = {
1588
1649
  list: newList,
1589
1650
  rules: currentRules
1590
1651
  };
1591
1652
  if (!deepEqual(newList, currentList)) await io.writeRoles(computed);
1592
- this.registry = registry;
1653
+ this.registry = entries;
1593
1654
  return outcome;
1594
1655
  }
1595
1656
  effectiveRoles(io) {
1596
1657
  return { roles: roleRows(io.read()).map((row) => {
1597
- const seedPersona = this.registry.get(row.id.trim());
1598
- const seeded = seedPersona !== void 0;
1658
+ const entry = this.registry.get(row.id.trim());
1659
+ const seeded = entry !== void 0;
1599
1660
  const effective = {
1600
1661
  id: row.id,
1601
1662
  persona: row.persona,
1602
1663
  seeded,
1603
- personaOverridden: seeded && row.persona !== seedPersona
1664
+ personaOverridden: seeded && row.persona !== entry.persona,
1665
+ source: seeded ? entry.set : "user"
1604
1666
  };
1605
- if (seeded) effective.seedPersona = seedPersona;
1667
+ if (seeded) effective.seedPersona = entry.persona;
1606
1668
  if (row.chain !== void 0) effective.chain = row.chain;
1607
1669
  if (row.fallback !== void 0) effective.fallback = row.fallback;
1608
1670
  return effective;
@@ -1611,19 +1673,20 @@ var FallbacksSeedManager = class {
1611
1673
  wireStatus(io) {
1612
1674
  const status = [];
1613
1675
  for (const row of roleRows(io.read())) {
1614
- const seedPersona = this.registry.get(row.id.trim());
1615
- if (seedPersona === void 0) continue;
1676
+ const entry = this.registry.get(row.id.trim());
1677
+ if (entry === void 0) continue;
1616
1678
  status.push({
1617
1679
  id: row.id,
1618
- overridden: row.persona !== seedPersona
1680
+ overridden: row.persona !== entry.persona,
1681
+ source: entry.set
1619
1682
  });
1620
1683
  }
1621
1684
  return status;
1622
1685
  }
1623
1686
  async revert(id, io) {
1624
1687
  const seedId = id.trim();
1625
- const seedPersona = this.registry.get(seedId);
1626
- if (seedPersona === void 0) return {
1688
+ const entry = this.registry.get(seedId);
1689
+ if (entry === void 0) return {
1627
1690
  reverted: false,
1628
1691
  reason: "not-seeded"
1629
1692
  };
@@ -1635,13 +1698,13 @@ var FallbacksSeedManager = class {
1635
1698
  reverted: false,
1636
1699
  reason: "row-absent"
1637
1700
  };
1638
- if (rows[index].persona === seedPersona) return {
1701
+ if (rows[index].persona === entry.persona) return {
1639
1702
  reverted: true,
1640
- persona: seedPersona
1703
+ persona: entry.persona
1641
1704
  };
1642
1705
  const nextList = rows.map((row, i) => i === index ? {
1643
1706
  ...row,
1644
- persona: seedPersona
1707
+ persona: entry.persona
1645
1708
  } : row);
1646
1709
  await io.writeRoles({
1647
1710
  list: nextList,
@@ -1649,7 +1712,7 @@ var FallbacksSeedManager = class {
1649
1712
  });
1650
1713
  return {
1651
1714
  reverted: true,
1652
- persona: seedPersona
1715
+ persona: entry.persona
1653
1716
  };
1654
1717
  }
1655
1718
  warnSkip(id, reason) {
@@ -1659,6 +1722,26 @@ var FallbacksSeedManager = class {
1659
1722
  else this.logger.warn(`llm-fallbacks: seeds: skipping seed id ${shown} — duplicate-in-batch (first wins)`);
1660
1723
  }
1661
1724
  };
1725
+ const RESERVED_SET_NAMES = [
1726
+ "bundled",
1727
+ "user",
1728
+ "external"
1729
+ ];
1730
+ function resolveSource(options, logger) {
1731
+ if (options?.bundled === true) return "bundled";
1732
+ const raw = options?.set;
1733
+ if (raw === void 0) return "external";
1734
+ if (typeof raw === "string" && raw.trim() !== "" && !RESERVED_SET_NAMES.includes(raw.trim())) return raw.trim();
1735
+ const shown = typeof raw === "string" ? JSON.stringify(raw) : String(raw);
1736
+ const reserved = RESERVED_SET_NAMES.map((name) => JSON.stringify(name)).join(" / ");
1737
+ 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
+ return "external";
1739
+ }
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
+ }
1662
1745
  function materialize(rows, registry, previous, conflicts) {
1663
1746
  const next = [];
1664
1747
  for (const row of rows) {
@@ -1733,14 +1816,6 @@ const presetRoles = [
1733
1816
  id: "scout",
1734
1817
  persona: "Read-only scout for exploratory codebase research, rapid analysis, and broad pattern search. Return compressed, structured findings another agent can reuse without re-reading the tree. Run searches in parallel; if a search is empty, try at least one alternate strategy before concluding the target is absent. Infer thoroughness from the task (quick, medium, or thorough; default medium); never write, edit, or run state-changing commands."
1735
1818
  },
1736
- {
1737
- id: "designer",
1738
- persona: "UI/UX specialist for design implementation, review, and visual refinement. Analyze the existing design system first (tokens, theme, and primitives) and compose with it; if none exists, define a minimal system before implementing. Cover loading, empty, error, disabled, hover, and focus states; verify accessibility (contrast, focus rings, semantic HTML) and responsive layout. Avoid generic AI-slop patterns; in review, cite file and line with a concrete issue and a specific fix."
1739
- },
1740
- {
1741
- id: "librarian",
1742
- persona: "Research specialist for external libraries and APIs who returns definitive, source-verified answers. Treat source as truth, documentation as aspiration, and training data as history; prefer locally installed packages, then official docs. Cross-check at least two locations; copy API signatures verbatim and report the investigated version. Stay read-only on the user's project; if a lookup is empty, try at least two fallback strategies before concluding nothing exists."
1743
- },
1744
1819
  {
1745
1820
  id: "reviewer",
1746
1821
  persona: "Code-review specialist for quality and security analysis of a patch before merge. Anchor every finding to the assigned diff; report only issues that are provable, actionable, unintentional, and introduced by the patch. For any new type, variant, or value that crosses a module boundary, inspect the consuming-side dispatch point. Rank findings P0 (blocks release) through P3 (nice to have); do not edit files or trigger builds."
@@ -2097,7 +2172,13 @@ function installTuiSettingsSection(ctx, opts) {
2097
2172
  ctx.inject(["tuiSettingsSections"], (tctx) => {
2098
2173
  const registry = tctx.tuiSettingsSections;
2099
2174
  if (registry === void 0) return;
2100
- return registry.register(buildFallbacksTuiSection());
2175
+ try {
2176
+ return registry.register(buildFallbacksTuiSection());
2177
+ } catch (error) {
2178
+ if (!(error instanceof Error) || !error.message.includes("already registered")) throw error;
2179
+ tctx.logger("llm-fallbacks").debug("llm-fallbacks: tui settings section already registered — no section on this fiber");
2180
+ return () => {};
2181
+ }
2101
2182
  });
2102
2183
  }
2103
2184
  //#endregion
@@ -2259,6 +2340,8 @@ function installFallbacksAdapter(ctx, readConfig) {
2259
2340
  const name = "llm-fallbacks";
2260
2341
  const provide = ["llm-fallbacks"];
2261
2342
  const { version } = createRequire(import.meta.url)("../package.json");
2343
+ const CONTEXT_WINDOW_EXCEEDED_CODE = "CONTEXT_WINDOW_EXCEEDED";
2344
+ const REQUEST_SCOPED_CODES = new Set([CONTEXT_WINDOW_EXCEEDED_CODE]);
2262
2345
  const stateStores = new WeakMap();
2263
2346
  function stateStore(ctx) {
2264
2347
  return stateStores.get(ctx);
@@ -2271,6 +2354,10 @@ const chainHeadStores = new WeakMap();
2271
2354
  function chainHeads(ctx) {
2272
2355
  return chainHeadStores.get(ctx);
2273
2356
  }
2357
+ const subagentRoleRecordStores = new WeakMap();
2358
+ function subagentRoleRecords(ctx) {
2359
+ return subagentRoleRecordStores.get(ctx);
2360
+ }
2274
2361
  function latestByAt(map) {
2275
2362
  let latest;
2276
2363
  for (const value of map.values()) if (latest === void 0 || value.at >= latest.at) latest = value;
@@ -2290,6 +2377,26 @@ async function makeModelExists(ctx, providers) {
2290
2377
  }));
2291
2378
  return (provider, model) => catalog.get(provider)?.has(model) ?? false;
2292
2379
  }
2380
+ async function makeContextWindowOf(ctx, routes) {
2381
+ const llm = ctx.get("llm");
2382
+ if (llm === void 0) return () => void 0;
2383
+ const windows = new Map();
2384
+ if (typeof llm.listModels === "function") await Promise.all([...new Set(routes.map((route) => route.provider))].map(async (provider) => {
2385
+ try {
2386
+ for (const model of await llm.listModels(provider)) if (typeof model.contextWindow === "number") windows.set(selectorKey(provider, model.id), model.contextWindow);
2387
+ } catch {}
2388
+ }));
2389
+ const resolveModelInfo = llm.resolveModelInfo?.bind(llm);
2390
+ if (resolveModelInfo !== void 0) await Promise.all(routes.map(async (route) => {
2391
+ const key = selectorKey(route.provider, route.model);
2392
+ if (windows.has(key)) return;
2393
+ try {
2394
+ const window = (await resolveModelInfo(route.provider, route.model))?.context?.contextWindow;
2395
+ if (typeof window === "number") windows.set(key, window);
2396
+ } catch {}
2397
+ }));
2398
+ return (provider, model) => windows.get(selectorKey(provider, model));
2399
+ }
2293
2400
  function countRetryEvents(session, turn, step, provider) {
2294
2401
  let count = 0;
2295
2402
  const events = session.snapshotEvents();
@@ -2367,6 +2474,24 @@ function apply(ctx, config = defaultFallbacksConfig) {
2367
2474
  };
2368
2475
  ctx.inject(["settings"], (sctx) => {
2369
2476
  writeRoles = (roles) => sctx.settings.update(FALLBACKS_SETTINGS_NAMESPACE, { roles });
2477
+ try {
2478
+ sctx.provide("llm-fallbacks", {
2479
+ name: "llm-fallbacks",
2480
+ version,
2481
+ resolveRole,
2482
+ resolveChain,
2483
+ validateFallbacksConfig,
2484
+ detectLegacyKeys,
2485
+ declareSeeds: (declarations, options) => seeds.declare(declarations, seedsIo, options?.set === void 0 ? void 0 : { set: options.set }),
2486
+ getEffectiveRoles: () => seeds.effectiveRoles(seedsIo),
2487
+ revertSeededPersona: (id) => seeds.revert(id, seedsIo)
2488
+ });
2489
+ serviceOwned = true;
2490
+ } catch (error) {
2491
+ serviceOwned = false;
2492
+ if (!(error instanceof Error) || !error.message.includes("has been registered")) throw error;
2493
+ ctx.logger("llm-fallbacks").debug("fallbacks service already registered — no service on this fiber (multi-fiber dedupe)");
2494
+ }
2370
2495
  return () => {
2371
2496
  writeRoles = () => {
2372
2497
  throw new Error(seedsSettingsUnavailable);
@@ -2377,25 +2502,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2377
2502
  read: () => source(),
2378
2503
  writeRoles: (roles) => writeRoles(roles)
2379
2504
  };
2380
- let serviceOwned = false;
2381
- try {
2382
- ctx.provide("llm-fallbacks", {
2383
- name: "llm-fallbacks",
2384
- version,
2385
- resolveRole,
2386
- resolveChain,
2387
- validateFallbacksConfig,
2388
- detectLegacyKeys,
2389
- declareSeeds: (declarations) => seeds.declare(declarations, seedsIo),
2390
- getEffectiveRoles: () => seeds.effectiveRoles(seedsIo),
2391
- revertSeededPersona: (id) => seeds.revert(id, seedsIo)
2392
- });
2393
- serviceOwned = true;
2394
- } catch (error) {
2395
- if (!(error instanceof Error) || !error.message.includes("has been registered")) throw error;
2396
- serviceOwned = false;
2397
- ctx.logger("llm-fallbacks").debug("fallbacks service already registered — no service on this fiber (multi-fiber dedupe)");
2398
- }
2505
+ let serviceOwned = ctx.get("llm-fallbacks") === void 0;
2399
2506
  let source = () => entry;
2400
2507
  const reconcileFallbacksAdapter = installFallbacksAdapter(ctx, () => source());
2401
2508
  validateFallbacksConfig(entry, logger);
@@ -2422,6 +2529,8 @@ function apply(ctx, config = defaultFallbacksConfig) {
2422
2529
  chainHeadStores.set(ctx, chainHeadMap);
2423
2530
  const blockedAttemptMap = new Map();
2424
2531
  blockedAttemptStores.set(ctx, blockedAttemptMap);
2532
+ const subagentRoleRecordMap = new Map();
2533
+ subagentRoleRecordStores.set(ctx, subagentRoleRecordMap);
2425
2534
  try {
2426
2535
  new FallbacksConfigGateway(ctx, bridge, seeds, () => {
2427
2536
  try {
@@ -2436,7 +2545,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2436
2545
  } catch {
2437
2546
  return { policy: { state: "unprovable" } };
2438
2547
  }
2439
- });
2548
+ }, () => subagentRoleRecordMap);
2440
2549
  } catch (error) {
2441
2550
  if (!(error instanceof Error) || !error.message.includes("has been registered")) throw error;
2442
2551
  ctx.logger("llm-fallbacks").debug("fallbacks gateway already registered — no gateway on this fiber (multi-fiber dedupe)");
@@ -2455,7 +2564,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2455
2564
  const dispatchInjected = new Set();
2456
2565
  const slotWinners = new Map();
2457
2566
  const lastKnownPolicySettings = new Map();
2458
- async function decide(agent, turn, step, current, reason, state) {
2567
+ async function decide(agent, turn, step, current, reason, state, failureCode) {
2459
2568
  const config = source();
2460
2569
  if (state !== void 0) {
2461
2570
  states.syncStep(state, turn, step);
@@ -2468,10 +2577,17 @@ function apply(ctx, config = defaultFallbacksConfig) {
2468
2577
  const modelExists = hasWildcardEntry(config.roles.list, rootTail, role) ? await makeModelExists(ctx, [...new Set(all.map((candidate) => candidate.provider))]) : void 0;
2469
2578
  const cooldown = { isSuppressed: (key) => state !== void 0 && states.isSuppressed(state, key, Date.now(), source().recovery ?? "timer") };
2470
2579
  const failed = { has: (key) => state !== void 0 && state.stepFailures.failed.has(key) };
2580
+ const contextWindowOf = failureCode === CONTEXT_WINDOW_EXCEEDED_CODE ? await makeContextWindowOf(ctx, [current, ...all.flatMap((candidate) => candidate.model === void 0 ? [] : [{
2581
+ provider: candidate.provider,
2582
+ model: candidate.model
2583
+ }])]) : void 0;
2584
+ const minContextWindow = contextWindowOf?.(current.provider, current.model);
2471
2585
  const surviving = selectCandidates(all, wildcard, createCandidateFilter({
2472
2586
  current,
2473
2587
  cooldown,
2474
- failed
2588
+ failed,
2589
+ minContextWindow,
2590
+ contextWindowOf
2475
2591
  }), modelExists);
2476
2592
  const first = surviving[0];
2477
2593
  let target;
@@ -2511,7 +2627,9 @@ function apply(ctx, config = defaultFallbacksConfig) {
2511
2627
  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, {
2512
2628
  current,
2513
2629
  cooldown,
2514
- failed
2630
+ failed,
2631
+ minContextWindow,
2632
+ contextWindowOf
2515
2633
  }).map(({ candidate, skip }) => skip === void 0 ? `${candidate.provider}/${candidate.model}` : `${candidate.provider}/${candidate.model} (skipped: ${skip})`));
2516
2634
  return {
2517
2635
  from: {
@@ -2529,10 +2647,12 @@ function apply(ctx, config = defaultFallbacksConfig) {
2529
2647
  function commit(state, pending, turn, step) {
2530
2648
  const config = source();
2531
2649
  const fromKey = selectorKey(pending.from.provider, pending.from.model);
2532
- 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;
2533
2650
  states.syncStep(state, turn, step);
2534
2651
  states.writePending(state, pending);
2535
- states.suppress(state, fromKey, until);
2652
+ if ((pending.scope ?? "route") === "route") {
2653
+ 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;
2654
+ states.suppress(state, fromKey, until);
2655
+ }
2536
2656
  states.recordFailure(state, fromKey);
2537
2657
  states.recordSwitch(state);
2538
2658
  }
@@ -2551,12 +2671,16 @@ function apply(ctx, config = defaultFallbacksConfig) {
2551
2671
  const current = currentModel(agent, provider);
2552
2672
  if (!current.model) return next();
2553
2673
  try {
2554
- const pending = await decide(agent, turn, step, current, "trigger-code", states.peek(agent.id));
2674
+ const pending = await decide(agent, turn, step, current, "trigger-code", states.peek(agent.id), failure.code);
2555
2675
  if (pending === null) {
2556
2676
  failHalfOpenProbe(agent.id, current);
2557
2677
  return next();
2558
2678
  }
2559
- commit(states.get(agent.id), pending, turn, step);
2679
+ const scope = REQUEST_SCOPED_CODES.has(failure.code) ? "request" : void 0;
2680
+ commit(states.get(agent.id), scope === void 0 ? pending : {
2681
+ ...pending,
2682
+ scope
2683
+ }, turn, step);
2560
2684
  return { kind: "retry" };
2561
2685
  } catch (error) {
2562
2686
  logger.warn("llm-fallbacks: decision path failed, passing the original failure through: %s", error?.message ?? String(error));
@@ -2628,6 +2752,14 @@ function apply(ctx, config = defaultFallbacksConfig) {
2628
2752
  model: head.model
2629
2753
  };
2630
2754
  }
2755
+ subagentRoleRecordMap.set(agent.id, {
2756
+ role,
2757
+ model: to ?? {
2758
+ provider: seed.provider,
2759
+ model: seed.model
2760
+ },
2761
+ at: Date.now()
2762
+ });
2631
2763
  if (to !== void 0 && !(to.provider === seed.provider && to.model === seed.model)) {
2632
2764
  if (policy.state === "enabled") chainHeadMap.set(agent.id, {
2633
2765
  at: Date.now(),
@@ -2676,6 +2808,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2676
2808
  slotWinners.delete(agent.id);
2677
2809
  blockedAttemptMap.delete(agent.id);
2678
2810
  chainHeadMap.delete(agent.id);
2811
+ subagentRoleRecordMap.delete(agent.id);
2679
2812
  lastKnownPolicySettings.delete(agent.id);
2680
2813
  });
2681
2814
  ctx.on("session/event", (session, event) => {
@@ -2694,6 +2827,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2694
2827
  slotWinners.clear();
2695
2828
  blockedAttemptMap.clear();
2696
2829
  chainHeadMap.clear();
2830
+ subagentRoleRecordMap.clear();
2697
2831
  lastKnownPolicySettings.clear();
2698
2832
  }, "llm-fallbacks: clear per-agent state");
2699
2833
  const fallbacksCommandController = {
@@ -2766,10 +2900,10 @@ function apply(ctx, config = defaultFallbacksConfig) {
2766
2900
  ctx.inject(["settings"], () => {
2767
2901
  if (!serviceOwned) return;
2768
2902
  if (seedsIo.read().presets === "none") return;
2769
- seeds.declare(presetRoles, seedsIo).catch((error) => {
2903
+ seeds.declare(presetRoles, seedsIo, { bundled: true }).catch((error) => {
2770
2904
  logger.error("llm-fallbacks: seeds: preset role declaration failed — %s", error?.message ?? String(error));
2771
2905
  });
2772
2906
  });
2773
2907
  }
2774
2908
  //#endregion
2775
- 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 };
2909
+ 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, subagentRoleRecords, validateFallbacksConfig };
package/dist/presets.d.ts CHANGED
@@ -1,17 +1,20 @@
1
1
  /**
2
- * Bundled preset role declarations (plan fallbacks-preset-roles Task 1).
2
+ * Bundled preset role declarations (plan fallbacks-preset-roles Task 1;
3
+ * trimmed 7 → 5 by the seed-source-provenance plan, D4).
3
4
  *
4
5
  * Derivation: distilled from the omp bundled agent prompts —
5
- * `packages/coding-agent/src/prompts/agents/{scout,designer,librarian,reviewer,security-reviewer}.md`
6
+ * `packages/coding-agent/src/prompts/agents/{scout,reviewer,security-reviewer}.md`
6
7
  * and `task.md` (task/sonic share the body; frontmatter injected in
7
8
  * `src/task/agents.ts`) — snapshot date 2026-08-16. Each persona is a
8
9
  * concise distillation (frontmatter description + core directives), NOT a
9
10
  * verbatim copy of the full prompt; the frozen text lives in spec
10
- * `fallbacks-preset-roles-spec.md` §9.2 (implementer SSOT).
11
+ * `fallbacks-preset-roles-spec.md` §9.2 (implementer SSOT). `designer` and
12
+ * `librarian` were removed from the bundled set; rows persisted by earlier
13
+ * versions survive untouched (they read back with source `user`).
11
14
  *
12
15
  * Pure data module: no io, no side effects, no classes. Types import only
13
16
  * `./seeds.ts` — no `@deepseek-ai/*` imports (bundle purity gate).
14
17
  */
15
18
  import type { SeedDeclaration } from './seeds.ts';
16
- /** The 7 bundled omp-style preset roles (spec §9.1 shape, §9.2 personas). */
19
+ /** The 5 bundled omp-style preset roles (spec §9.1 shape, §9.2 personas). */
17
20
  export declare const presetRoles: readonly SeedDeclaration[];
package/dist/seeds.d.ts CHANGED
@@ -11,10 +11,13 @@
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, seedPersona>`),
15
- * declare = replacement (the batch is the companion's full current set).
16
- * `seeded` / `personaOverridden` are DERIVED at read time, never stored —
17
- * a config round-trip cannot orphan an override (AC-3).
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).
17
+ * `seeded` / `personaOverridden` / `source` are DERIVED at read time, never
18
+ * stored — a config round-trip cannot orphan an override (AC-3), and
19
+ * provenance rides the in-memory registry only, so it can never churn a
20
+ * settings write.
18
21
  *
19
22
  * Materialization (spec §9.2): append `{ id, persona }` (two keys only,
20
23
  * R4) / attach row untouched / at-default tracking / override preserved +
@@ -31,6 +34,41 @@ export interface SeedDeclaration {
31
34
  id: string;
32
35
  persona: string;
33
36
  }
37
+ /**
38
+ * Per-row provenance label (spec §2). The fixed labels: `bundled` (the
39
+ * plugin's own bundled preset self-declare), `external` (a companion
40
+ * declare without a set name — or with an invalid one, degraded), `user`
41
+ * (no live declaration). Any other value is a declared set name; the
42
+ * reserved names can never be declared as a set, so the fixed labels are
43
+ * unambiguous.
44
+ */
45
+ export type SeedSource = 'bundled' | 'external' | 'user' | (string & {});
46
+ /**
47
+ * Optional provenance for one `declare()` call (spec §2) — the PUBLIC
48
+ * options face (a companion's registered set name). This is also the type
49
+ * of the `FallbacksService.declareSeeds` second parameter: consumers can
50
+ * never label a declare `bundled` (reserved).
51
+ */
52
+ export interface SeedsDeclareOptions {
53
+ /**
54
+ * Registered set name — trimmed. Empty after trim, non-string, or a
55
+ * reserved value (`bundled` / `user` / `external`) warns once and
56
+ * degrades the batch to the unnamed `external` source; the seeds still
57
+ * apply (a bad label must never drop roles).
58
+ */
59
+ set?: string;
60
+ }
61
+ /**
62
+ * The manager-level declare options — the public face plus the INTERNAL
63
+ * bundled marker for the plugin's own preset self-declare. Module-internal
64
+ * (never exported, never re-exported from the package root): only the
65
+ * preset child call site passes `bundled`, and only a structurally-checked
66
+ * object literal at that — the type itself is not nameable by consumers.
67
+ */
68
+ interface SeedsInternalDeclareOptions extends SeedsDeclareOptions {
69
+ /** INTERNAL — bundled preset self-declare provenance. Not a consumer option. */
70
+ bundled?: true;
71
+ }
34
72
  /**
35
73
  * Why one declared id was skipped (spec §9.1). Per-id skip + warn, never
36
74
  * coercion — valid siblings in the same batch still apply (AC-5).
@@ -68,6 +106,8 @@ export interface EffectiveRole {
68
106
  seeded: boolean;
69
107
  /** `seeded` && row persona !== current seed default. */
70
108
  personaOverridden: boolean;
109
+ /** Where the row's seed state comes from (read-time derived, never persisted) — `user` iff not seeded. */
110
+ source: SeedSource;
71
111
  /** Present iff seeded. */
72
112
  seedPersona?: string;
73
113
  }
@@ -86,6 +126,12 @@ export interface SeedRevertOutcome {
86
126
  export interface SeedsWireStatus {
87
127
  id: string;
88
128
  overridden: boolean;
129
+ /**
130
+ * Provenance label (spec §2) — additive and OPTIONAL on the wire: a
131
+ * gateway predating the field (version skew) sends entries without it;
132
+ * consumers treat a missing label as "no badge".
133
+ */
134
+ source?: SeedSource;
89
135
  }
90
136
  /**
91
137
  * The narrow IO seam this module writes through — keeps `src/seeds.ts`
@@ -105,6 +151,7 @@ export interface SeedsIo {
105
151
  */
106
152
  export declare class FallbacksSeedManager {
107
153
  private readonly logger;
154
+ /** Per-apply declaration registry: declared default persona + provenance label. */
108
155
  private registry;
109
156
  constructor(logger: FallbacksConfigLogger);
110
157
  /**
@@ -112,6 +159,10 @@ export declare class FallbacksSeedManager {
112
159
  * companion's FULL current declaration set; ids omitted from the batch
113
160
  * drop out of the registry while their rows remain (R2).
114
161
  *
162
+ * `options` labels the whole batch's provenance (spec §2), resolved once
163
+ * per call: an invalid set name warns once and degrades to the unnamed
164
+ * `external` case — the seeds still apply.
165
+ *
115
166
  * Per-id validation AS DECLARED (spec §9.3): non-string / pattern miss /
116
167
  * reserved `inherit` / duplicate-in-batch → skip + warn; valid siblings
117
168
  * still apply (AC-5). Materializes per spec §9.2, writes only when the
@@ -119,15 +170,17 @@ export declare class FallbacksSeedManager {
119
170
  * (idempotent, AC-1), and commits the registry only after a successful
120
171
  * write (compute → write → commit; retry-safe).
121
172
  */
122
- declare(seeds: readonly SeedDeclaration[], io: SeedsIo): Promise<SeedDeclareOutcome>;
173
+ declare(seeds: readonly SeedDeclaration[], io: SeedsIo, options?: SeedsInternalDeclareOptions): Promise<SeedDeclareOutcome>;
123
174
  /**
124
175
  * Readback (b) — sync, derived: every config row annotated with
125
176
  * `seeded` / `personaOverridden` / `seedPersona` (trimmed row-id
126
- * membership in the live declaration set; persona inequality). Nothing
177
+ * 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
127
180
  * override-shaped is stored, so a config round-trip cannot orphan state.
128
181
  */
129
182
  effectiveRoles(io: SeedsIo): EffectiveRolesReadback;
130
- /** Card badge state (spec §9.4): seeded rows, with the override flag. */
183
+ /** Card badge state (spec §9.4): seeded rows, with the override flag and the provenance label. */
131
184
  wireStatus(io: SeedsIo): SeedsWireStatus[];
132
185
  /**
133
186
  * Revert one id to the CURRENT declared seed default (AC-3). Writes
@@ -139,3 +192,4 @@ export declare class FallbacksSeedManager {
139
192
  revert(id: string, io: SeedsIo): Promise<SeedRevertOutcome>;
140
193
  private warnSkip;
141
194
  }
195
+ export {};