dsh-llm-fallbacks 0.7.0 → 0.8.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
@@ -227,18 +227,18 @@ function isAllDayConforming(chain) {
227
227
  const tail = chain[chain.length - 1];
228
228
  return isOfficialAllDayId(tail);
229
229
  }
230
- function resolveEffectiveChain(config, now, tz) {
231
- const state = resolveSlotState(config, now, tz);
232
- return state.winner === "all-day" ? config.rootChain : state.winner.chain;
230
+ function resolveEffectiveChain(chain, now, tz) {
231
+ const state = resolveSlotState(chain, now, tz);
232
+ return state.winner === "all-day" ? chain.entries : state.winner.chain;
233
233
  }
234
- function resolveSlotState(config, now, tz) {
235
- if (!isAllDayConforming(config.rootChain)) return {
234
+ function resolveSlotState(chain, now, tz) {
235
+ if (!isAllDayConforming(chain.entries)) return {
236
236
  winner: "all-day",
237
237
  label: "all-day"
238
238
  };
239
239
  const clock = wallClock(now, tz);
240
240
  const seenPresets = new Set();
241
- const rows = Array.isArray(config.timeSlots) ? config.timeSlots : [];
241
+ const rows = Array.isArray(chain.timeSlots) ? chain.timeSlots : [];
242
242
  for (const row of rows) {
243
243
  if (typeof row !== "object" || row === null) {
244
244
  warnMalformed(row, "row is not an object");
@@ -324,31 +324,57 @@ var RecoveryStore = class {
324
324
  };
325
325
  //#endregion
326
326
  //#region .build/host/config.js
327
+ const CHAIN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]{0,31}$/;
327
328
  const defaultFallbacksConfig = {
328
329
  triggerCodes: [
329
330
  "AUTH",
330
331
  "QUOTA",
331
332
  "RATE_LIMIT"
332
333
  ],
333
- rootChain: [],
334
- roles: {
335
- list: [],
336
- rules: []
337
- },
334
+ chains: [],
335
+ roles: { list: [] },
338
336
  cooldownMs: 3e5,
339
337
  revertPolicy: "cooldown-expiry",
340
338
  maxSwitchesPerStep: 8,
341
339
  presets: "bundled",
342
- timeSlots: [],
343
340
  tz: "Asia/Shanghai",
344
341
  recovery: "timer"
345
342
  };
343
+ const ROLE_ID_PATTERN = /^[a-z0-9-]{1,32}$/;
346
344
  const INHERIT_ROLE_ID = "inherit";
347
345
  function isFallbackActive(config) {
348
- return config.rootChain.length > 0 || (config.timeSlots?.length ?? 0) > 0 || config.roles.list.length > 0;
346
+ return config.chains.length > 0;
349
347
  }
350
- const ROLE_ID_PATTERN = /^[a-z0-9-]{1,32}$/;
351
348
  function validateFallbacksConfig(config, logger) {
349
+ const declaredChainIds = new Set();
350
+ for (const chain of config.chains) {
351
+ if (!CHAIN_ID_PATTERN.test(chain.id)) logger.warn(`llm-fallbacks: invalid chain id "${chain.id}" — must match /^[A-Za-z0-9][A-Za-z0-9-]{0,31}$/`);
352
+ if (declaredChainIds.has(chain.id)) logger.warn(`llm-fallbacks: duplicate chain id "${chain.id}" — chain ids must be unique (case-sensitive)`);
353
+ declaredChainIds.add(chain.id);
354
+ if (chain.entries.length > 0 && !isAllDayConforming(chain.entries)) logger.warn(`llm-fallbacks: chain "${chain.id}" must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-v4-pro) — its time-slot rows and the virtual picker delegate stay inert until the tail conforms`);
355
+ for (const entry of chain.entries) try {
356
+ parseSelector(entry);
357
+ } catch (error) {
358
+ logger.warn(`llm-fallbacks: ignoring invalid chain "${chain.id}" entry "${entry}": ${error.message}`);
359
+ }
360
+ if (Array.isArray(chain.timeSlots)) {
361
+ const seenSlotPresets = new Set();
362
+ for (const row of chain.timeSlots) {
363
+ if (row.kind !== "preset") continue;
364
+ const preset = row.preset;
365
+ if (typeof preset !== "string" || !Object.hasOwn(PRESETS, preset)) {
366
+ logger.warn(`llm-fallbacks: chain "${chain.id}" has an unknown time-slot preset ${JSON.stringify(preset)} — row ignored`);
367
+ continue;
368
+ }
369
+ if (seenSlotPresets.has(preset)) {
370
+ logger.warn(`llm-fallbacks: chain "${chain.id}" has a duplicate time-slot preset "${preset}" — only the first row takes effect`);
371
+ continue;
372
+ }
373
+ seenSlotPresets.add(preset);
374
+ if (row.start !== void 0 || row.end !== void 0 || row.days !== void 0 && row.days.length > 0) logger.warn(`llm-fallbacks: chain "${chain.id}" preset "${preset}" carries its own window/day fields — preset windows are fixed code constants; the row is ignored`);
375
+ }
376
+ }
377
+ }
352
378
  const declaredIds = new Set();
353
379
  for (const role of config.roles.list) {
354
380
  const id = role.id.trim();
@@ -356,106 +382,150 @@ function validateFallbacksConfig(config, logger) {
356
382
  if (id === "inherit") logger.warn(`llm-fallbacks: role id "${role.id}" is reserved — "inherit" cannot be declared in roles.list`);
357
383
  if (declaredIds.has(id)) logger.warn(`llm-fallbacks: duplicate role id "${role.id}" — role ids must be unique`);
358
384
  declaredIds.add(id);
359
- for (const entry of role.chain ?? []) try {
360
- parseSelector(entry);
361
- } catch (error) {
362
- logger.warn(`llm-fallbacks: ignoring invalid chain entry "${entry}" in role "${role.id}": ${error.message}`);
363
- }
364
- 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`);
365
- 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"`);
366
- }
367
- 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-v4-pro) — time-slot rows and the virtual picker row stay inert until the all-day chain tail conforms");
368
- for (const entry of config.rootChain) try {
369
- parseSelector(entry);
370
- } catch (error) {
371
- logger.warn(`llm-fallbacks: ignoring invalid rootChain entry "${entry}": ${error.message}`);
372
- }
373
- const validTargets = new Set([...declaredIds, INHERIT_ROLE_ID]);
374
- for (const rule of config.roles.rules) if (!validTargets.has(rule.role.trim())) logger.warn(`llm-fallbacks: rule references undeclared role "${rule.role}" — expected one of roles.list ids or "inherit"`);
375
- if (Array.isArray(config.timeSlots)) {
376
- const seenSlotPresets = new Set();
377
- for (const row of config.timeSlots) {
378
- if (row.kind !== "preset") continue;
379
- const preset = row.preset;
380
- if (typeof preset !== "string" || !Object.hasOwn(PRESETS, preset)) {
381
- logger.warn(`llm-fallbacks: unknown time-slot preset ${JSON.stringify(preset)} — row ignored`);
382
- continue;
383
- }
384
- if (seenSlotPresets.has(preset)) {
385
- logger.warn(`llm-fallbacks: duplicate time-slot preset "${preset}" — only the first row takes effect`);
386
- continue;
387
- }
388
- seenSlotPresets.add(preset);
389
- if (row.start !== void 0 || row.end !== void 0 || row.days !== void 0 && row.days.length > 0) logger.warn(`llm-fallbacks: preset "${preset}" carries its own window/day fields — preset windows are fixed code constants; the row is ignored`);
390
- }
385
+ if (role.chain !== void 0 && role.chain !== "" && !declaredChainIds.has(role.chain)) logger.warn(`llm-fallbacks: role "${role.id}" specifies unknown chain "${role.chain}" — the child inherits the delegating agent's route`);
391
386
  }
392
387
  if ((config.recovery ?? "timer") === "half-open" && config.cooldownMs >= 36e5) logger.warn(`llm-fallbacks: recovery "half-open" escalation is inert — cooldownMs (${config.cooldownMs} ms) is at or above the 1-hour escalation cap (${ESCALATION_CAP_MS} ms), so every suppression stays flat at cooldownMs`);
393
388
  }
394
389
  function detectLegacyKeys(source) {
395
- const keys = [];
396
- if (Object.hasOwn(source, "chains")) keys.push("chains");
397
- if (Object.hasOwn(source, "alwaysModeRetryCap")) keys.push("alwaysModeRetryCap");
398
- if (Object.hasOwn(source, "roleAutoMatch")) keys.push("roleAutoMatch");
390
+ const keys = new Set();
391
+ if (Object.hasOwn(source, "alwaysModeRetryCap")) keys.add("alwaysModeRetryCap");
392
+ if (Object.hasOwn(source, "roleAutoMatch")) keys.add("roleAutoMatch");
393
+ if (Object.hasOwn(source, "rootChain")) keys.add("rootChain");
394
+ if (Object.hasOwn(source, "timeSlots")) keys.add("timeSlots");
399
395
  const roles = source.roles;
400
396
  if (isRecordLike(roles)) {
401
- if (Object.hasOwn(roles, "default")) keys.push("roles.default");
402
- const declared = new Set();
397
+ if (Object.hasOwn(roles, "default")) keys.add("roles.default");
398
+ if (Object.hasOwn(roles, "rules")) keys.add("roles.rules");
403
399
  if (Array.isArray(roles.list)) for (const item of roles.list) {
404
400
  if (!isRecordLike(item)) continue;
405
- if (typeof item.id === "string") declared.add(item.id);
406
- if (Object.hasOwn(item, "label")) keys.push("roles.list[].label");
407
- if (Object.hasOwn(item, "description")) keys.push("roles.list[].description");
408
- }
409
- if (Array.isArray(roles.rules)) {
410
- for (const rule of roles.rules) if (isRecordLike(rule) && typeof rule.role === "string" && rule.role !== "inherit" && !declared.has(rule.role)) keys.push(`roles.rules[].role: ${rule.role}`);
401
+ if (Object.hasOwn(item, "label")) keys.add("roles.list[].label");
402
+ if (Object.hasOwn(item, "description")) keys.add("roles.list[].description");
403
+ if (Object.hasOwn(item, "fallback")) keys.add("roles.list[].fallback");
404
+ if (Array.isArray(item.chain)) keys.add("roles.list[].chain");
411
405
  }
412
406
  }
413
- return keys;
407
+ return [...keys];
414
408
  }
415
409
  function isRecordLike(value) {
416
410
  return typeof value === "object" && value !== null && !Array.isArray(value);
417
411
  }
412
+ const MIGRATED_CHAIN_ID = "Auto";
413
+ const MIGRATED_CHAIN_LABEL = "默认链";
414
+ function migrateLegacyConfig(source) {
415
+ const raw = isRecordLike(source) ? source : {};
416
+ const defaults = defaultFallbacksConfig;
417
+ return {
418
+ triggerCodes: stringArrayOf(raw["triggerCodes"]) ?? [...defaults.triggerCodes],
419
+ chains: migrateChains(raw),
420
+ roles: { list: migrateRoles(raw["roles"]) },
421
+ cooldownMs: finiteNumberOr(raw["cooldownMs"], defaults.cooldownMs),
422
+ revertPolicy: raw["revertPolicy"] === "never" ? "never" : defaults.revertPolicy,
423
+ maxSwitchesPerStep: finiteNumberOr(raw["maxSwitchesPerStep"], defaults.maxSwitchesPerStep),
424
+ presets: raw["presets"] === "none" ? "none" : defaults.presets,
425
+ tz: typeof raw["tz"] === "string" && raw["tz"] !== "" ? raw["tz"] : defaults.tz,
426
+ recovery: raw["recovery"] === "half-open" ? "half-open" : defaults.recovery
427
+ };
428
+ }
429
+ function migrateChains(raw) {
430
+ const declared = Array.isArray(raw["chains"]) ? raw["chains"] : void 0;
431
+ if (declared !== void 0 && declared.length > 0) {
432
+ const chains = [];
433
+ for (const entry of declared) {
434
+ const chain = normalizeChain(entry);
435
+ if (chain !== void 0) chains.push(chain);
436
+ }
437
+ return chains;
438
+ }
439
+ const rootChain = stringArrayOf(raw["rootChain"]);
440
+ const legacyRows = Array.isArray(raw["timeSlots"]) ? raw["timeSlots"].filter(isRecordLike).map((row) => ({ ...row })) : [];
441
+ if (rootChain === void 0 || rootChain.length === 0) return [];
442
+ return [{
443
+ id: MIGRATED_CHAIN_ID,
444
+ label: MIGRATED_CHAIN_LABEL,
445
+ entries: [...rootChain],
446
+ timeSlots: legacyRows
447
+ }];
448
+ }
449
+ function normalizeChain(value) {
450
+ if (!isRecordLike(value)) return void 0;
451
+ const id = typeof value["id"] === "string" ? value["id"] : void 0;
452
+ if (id === void 0) return void 0;
453
+ const timeSlots = Array.isArray(value["timeSlots"]) ? value["timeSlots"].filter(isRecordLike).map((row) => ({ ...row })) : [];
454
+ return {
455
+ id,
456
+ label: typeof value["label"] === "string" ? value["label"] : id,
457
+ entries: stringArrayOf(value["entries"]) ?? [],
458
+ timeSlots
459
+ };
460
+ }
461
+ function migrateRoles(value) {
462
+ if (!isRecordLike(value)) return [];
463
+ const list = Array.isArray(value["list"]) ? value["list"] : [];
464
+ const roles = [];
465
+ for (const row of list) {
466
+ if (!isRecordLike(row)) continue;
467
+ const id = typeof row["id"] === "string" ? row["id"] : void 0;
468
+ if (id === void 0) continue;
469
+ const role = {
470
+ ...row,
471
+ id,
472
+ persona: typeof row["persona"] === "string" ? row["persona"] : ""
473
+ };
474
+ for (const stale of [
475
+ "fallback",
476
+ "label",
477
+ "description"
478
+ ]) delete role[stale];
479
+ if (typeof row["chain"] !== "string" || row["chain"] === "") delete role["chain"];
480
+ roles.push(role);
481
+ }
482
+ return roles;
483
+ }
484
+ function stringArrayOf(value) {
485
+ if (!Array.isArray(value)) return void 0;
486
+ const out = [];
487
+ for (const item of value) {
488
+ if (typeof item !== "string") return void 0;
489
+ out.push(item);
490
+ }
491
+ return out;
492
+ }
493
+ function finiteNumberOr(value, fallback) {
494
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
495
+ }
418
496
  const Config = z.object({
419
497
  triggerCodes: z.array(z.string()).default([
420
498
  "AUTH",
421
499
  "QUOTA",
422
500
  "RATE_LIMIT"
423
501
  ]),
424
- rootChain: z.array(z.string()).default([]),
425
- roles: z.object({
426
- list: z.array(z.object({
427
- id: z.string().required(),
428
- persona: z.string().default(""),
429
- prompt: z.string(),
430
- permissions: z.object({
431
- allow: z.array(z.string()),
432
- deny: z.array(z.string())
433
- }),
434
- chain: z.array(z.string()),
435
- fallback: z.union([z.const("inherit-root"), z.const("none")]).default("inherit-root")
436
- })).default([]),
437
- rules: z.array(z.object({
438
- origin: z.union([z.const("root"), z.const("subagent")]),
439
- provider: z.string(),
440
- model: z.string(),
441
- role: z.string().required()
502
+ chains: z.array(z.object({
503
+ id: z.string().required(),
504
+ label: z.string().required(),
505
+ entries: z.array(z.string()).default([]),
506
+ timeSlots: z.array(z.object({
507
+ kind: z.string(),
508
+ preset: z.string(),
509
+ start: z.string(),
510
+ end: z.string(),
511
+ days: z.array(z.number()),
512
+ chain: z.array(z.string())
442
513
  })).default([])
443
- }).default({
444
- list: [],
445
- rules: []
446
- }),
514
+ })).default([]),
515
+ roles: z.object({ list: z.array(z.object({
516
+ id: z.string().required(),
517
+ persona: z.string().default(""),
518
+ prompt: z.string(),
519
+ permissions: z.object({
520
+ allow: z.array(z.string()),
521
+ deny: z.array(z.string())
522
+ }),
523
+ chain: z.union([z.string(), z.array(z.string())])
524
+ })).default([]) }).default({ list: [] }),
447
525
  cooldownMs: z.number().default(3e5),
448
526
  revertPolicy: z.union([z.const("cooldown-expiry"), z.const("never")]).default("cooldown-expiry"),
449
527
  maxSwitchesPerStep: z.number().default(8),
450
528
  presets: z.union([z.const("bundled"), z.const("none")]).default("bundled"),
451
- timeSlots: z.array(z.object({
452
- kind: z.string(),
453
- preset: z.string(),
454
- start: z.string(),
455
- end: z.string(),
456
- days: z.array(z.number()),
457
- chain: z.array(z.string())
458
- })).default([]),
459
529
  tz: z.string().default("Asia/Shanghai"),
460
530
  recovery: z.union([z.const("timer"), z.const("half-open")]).default("timer")
461
531
  }).extra("volatile", true);
@@ -475,19 +545,11 @@ function resolveCandidate(entry, failing, modelExists) {
475
545
  }
476
546
  return selector;
477
547
  }
478
- function roleChainEntries(roles, rootChain, role) {
479
- if (role.trim() === "inherit") return rootChain;
480
- const roleDef = roles.find((declared) => declared.id.trim() === role.trim());
481
- if (roleDef === void 0) return rootChain;
482
- return [...roleDef.chain ?? [], ...roleDef.fallback === "none" ? [] : rootChain];
483
- }
484
- function resolveChainViews(roles, rootChain, role, provider, model, warn = console.warn) {
548
+ function resolveChainViews(entries, provider, model, warn = console.warn) {
485
549
  const failing = {
486
550
  provider,
487
551
  model
488
552
  };
489
- const entries = roleChainEntries(roles, rootChain, role);
490
- if (role.trim() !== "inherit" && !roles.some((declared) => declared.id.trim() === role.trim())) warn(`llm-fallbacks: unknown role "${role}" — falling back to rootChain`);
491
553
  const all = [];
492
554
  const wildcard = [];
493
555
  for (const entry of entries) {
@@ -517,12 +579,11 @@ function selectCandidates(all, wildcard, filter, modelExists) {
517
579
  }
518
580
  return surviving;
519
581
  }
520
- function resolveChain(roles, rootChain, role, provider, model, filter, modelExists, warn = console.warn) {
521
- const { all, wildcard } = resolveChainViews(roles, rootChain, role, provider, model, warn);
582
+ function resolveChain(entries, provider, model, filter, modelExists, warn = console.warn) {
583
+ const { all, wildcard } = resolveChainViews(entries, provider, model, warn);
522
584
  return selectCandidates(all, wildcard, filter, modelExists);
523
585
  }
524
- function hasWildcardEntry(roles, rootChain, role) {
525
- const entries = roleChainEntries(roles, rootChain, role);
586
+ function hasWildcardEntry(entries) {
526
587
  for (const entry of entries) try {
527
588
  if (parseSelector(entry).model === void 0) return true;
528
589
  } catch {}
@@ -574,66 +635,9 @@ function annotateCandidates(candidates, surviving, options) {
574
635
  });
575
636
  }
576
637
  //#endregion
577
- //#region .build/host/route-allowlist.js
578
- function resolvedRoutes(candidates) {
579
- const routes = [];
580
- for (const candidate of candidates) {
581
- if (candidate.model === void 0) continue;
582
- routes.push({
583
- provider: candidate.provider,
584
- model: candidate.model
585
- });
586
- }
587
- return routes;
588
- }
589
- function routeOnAllowlist(route, allowedModels) {
590
- return allowedModels.some((allowed) => allowed.provider === route.provider && allowed.model === route.model);
591
- }
592
- function firstAllowedCandidate(resolved, allowedModels) {
593
- return resolved.find((route) => routeOnAllowlist(route, allowedModels));
594
- }
595
- //#endregion
596
- //#region .build/host/roles.js
597
- function resolveRole(agent, rules, roleIds, warn = console.warn, servedRoute) {
598
- if ((agent.session?.header?.origin ?? "root") !== "subagent") return INHERIT_ROLE_ID;
599
- const recordedRoute = {
600
- provider: agent.options?.provider,
601
- model: agent.options?.model
602
- };
603
- for (const rule of rules) {
604
- const matchesPair = (pair) => (!rule.provider || rule.provider === pair.provider) && (!rule.model || rule.model === pair.model);
605
- if (!matchesPair(recordedRoute) && !(servedRoute !== void 0 && matchesPair(servedRoute))) continue;
606
- const target = rule.role.trim();
607
- if (target === "inherit") return INHERIT_ROLE_ID;
608
- const declared = roleIds.get(target);
609
- if (declared === void 0) {
610
- warn(`llm-fallbacks: rule references undeclared role "${rule.role}" — falling back to "inherit"`);
611
- return INHERIT_ROLE_ID;
612
- }
613
- return declared;
614
- }
615
- return INHERIT_ROLE_ID;
616
- }
617
- //#endregion
618
- //#region .build/host/role-resolution.js
619
- function resolveDeclaredRole(agent, rules, roleIds, opts) {
620
- const preset = agent.session?.header?.agentPreset?.trim();
621
- if (preset !== void 0 && preset !== "" && preset !== "inherit") {
622
- const declared = roleIds.get(preset);
623
- if (declared !== void 0) return declared;
624
- }
625
- const dispatch = opts.dispatchRole?.trim();
626
- if (dispatch !== void 0 && dispatch !== "" && dispatch !== "inherit") {
627
- const declared = roleIds.get(dispatch);
628
- if (declared !== void 0) return declared;
629
- }
630
- return resolveRole(agent, rules, roleIds, opts.warn, opts.servedRoute);
631
- }
632
- //#endregion
633
638
  //#region .build/host/virtual-adapter.js
634
639
  const FALLBACKS_PROVIDER$1 = "FallbacksChain";
635
- const FALLBACKS_CHAIN_MODEL = "Auto";
636
- function firstDispatchableExactHead(chain, allowedModels) {
640
+ function firstDispatchableExactHead(chain) {
637
641
  for (const entry of chain) {
638
642
  let selector;
639
643
  try {
@@ -643,24 +647,22 @@ function firstDispatchableExactHead(chain, allowedModels) {
643
647
  }
644
648
  if (selector.model === void 0) continue;
645
649
  if (selector.provider === "FallbacksChain") continue;
646
- const head = {
650
+ return {
647
651
  provider: selector.provider,
648
652
  model: selector.model
649
653
  };
650
- if (allowedModels !== void 0 && !routeOnAllowlist(head, allowedModels)) continue;
651
- return head;
652
654
  }
653
655
  }
654
- function effectiveHeadOf(config, now) {
655
- if (!isAllDayConforming(config.rootChain)) return void 0;
656
- return firstDispatchableExactHead(resolveEffectiveChain(config, now, config.tz ?? "Asia/Shanghai"));
656
+ function effectiveHeadOf(chain, now, tz = "Asia/Shanghai") {
657
+ if (!isAllDayConforming(chain.entries)) return void 0;
658
+ return firstDispatchableExactHead(resolveEffectiveChain(chain, now, tz));
657
659
  }
658
- function pickerDisplayName(config, now = new Date(), modelDisplayName) {
659
- const head = effectiveHeadOf(config, now);
660
- if (head === void 0) return FALLBACKS_CHAIN_MODEL;
661
- const slot = resolveSlotState(config, now, config.tz ?? "Asia/Shanghai");
660
+ function pickerDisplayName(chain, now = new Date(), modelDisplayName, tz = "Asia/Shanghai") {
661
+ const head = effectiveHeadOf(chain, now, tz);
662
+ if (head === void 0) return chain.label;
663
+ const slot = resolveSlotState(chain, now, tz);
662
664
  const model = modelDisplayName !== void 0 && modelDisplayName !== "" ? modelDisplayName : head.model;
663
- return `${FALLBACKS_CHAIN_MODEL}: ${model}[${slot.label}]`;
665
+ return `${chain.label}: ${model}[${slot.label}]`;
664
666
  }
665
667
  async function resolveHeadDisplayName(llm, head) {
666
668
  if (llm === void 0) return head.model;
@@ -693,16 +695,20 @@ var FallbacksChainAdapter = class extends LlmAdapter {
693
695
  async listModels(provider) {
694
696
  const config = this.readConfig();
695
697
  const now = new Date();
696
- const head = effectiveHeadOf(config, now);
697
- const display = head === void 0 ? void 0 : await resolveHeadDisplayName(this.getLlm(), head);
698
- return [{
699
- provider,
700
- id: FALLBACKS_CHAIN_MODEL,
701
- name: pickerDisplayName(config, now, display)
702
- }];
698
+ const tz = config.tz ?? "Asia/Shanghai";
699
+ return Promise.all(config.chains.map(async (chain) => {
700
+ const head = effectiveHeadOf(chain, now, tz);
701
+ const display = head === void 0 ? void 0 : await resolveHeadDisplayName(this.getLlm(), head);
702
+ return {
703
+ provider,
704
+ id: chain.id,
705
+ name: pickerDisplayName(chain, now, display, tz)
706
+ };
707
+ }));
703
708
  }
704
709
  async resolveModel(provider, model, signal) {
705
- const head = effectiveHeadOf(this.readConfig(), new Date());
710
+ const chain = this.readConfig().chains.find((entry) => entry.id === model);
711
+ const head = chain === void 0 ? void 0 : effectiveHeadOf(chain, new Date(), this.readConfig().tz ?? "Asia/Shanghai");
706
712
  const llm = this.getLlm();
707
713
  if (head !== void 0 && llm !== void 0) try {
708
714
  const info = await llm.resolveModelInfo(head.provider, head.model, signal);
@@ -723,18 +729,11 @@ var FallbacksChainAdapter = class extends LlmAdapter {
723
729
  name: model
724
730
  };
725
731
  }
726
- providerRetryPolicy(_provider) {
732
+ providerRetryPolicy(_provider) {}
733
+ imageRequestPricing(_provider, model) {
727
734
  try {
728
- const head = effectiveHeadOf(this.readConfig(), new Date());
729
- if (head === void 0 || head.provider === "FallbacksChain") return void 0;
730
- return this.getLlm()?.providerRetryPolicy(head.provider);
731
- } catch {
732
- return;
733
- }
734
- }
735
- imageRequestPricing(_provider, _model) {
736
- try {
737
- const head = effectiveHeadOf(this.readConfig(), new Date());
735
+ const chain = this.readConfig().chains.find((entry) => entry.id === model);
736
+ const head = chain === void 0 ? void 0 : effectiveHeadOf(chain, new Date(), this.readConfig().tz ?? "Asia/Shanghai");
738
737
  if (head === void 0 || head.provider === "FallbacksChain") return void 0;
739
738
  return this.getLlm()?.imageRequestPricing(head.provider, head.model);
740
739
  } catch {
@@ -758,7 +757,7 @@ function installFallbacksAdapter(ctx, readConfig, walk) {
758
757
  try {
759
758
  disposeAdapter = llm.registerAdapter([FALLBACKS_PROVIDER$1], adapter);
760
759
  registered = true;
761
- logger.info("llm-fallbacks: virtual adapter registered — FallbacksChain/Auto is selectable in the model picker");
760
+ logger.info("llm-fallbacks: virtual adapter registered — FallbacksChain/<chain id> rows are selectable in the model picker");
762
761
  } catch (error) {
763
762
  if (!(error instanceof Error) || !("code" in error) || error.code !== "DUPLICATE_ADAPTER") throw error;
764
763
  logger.debug("llm-fallbacks: virtual adapter already registered — no route on this fiber (multi-fiber dedupe)");
@@ -767,7 +766,7 @@ function installFallbacksAdapter(ctx, readConfig, walk) {
767
766
  disposeAdapter?.();
768
767
  disposeAdapter = void 0;
769
768
  registered = false;
770
- logger.info("llm-fallbacks: virtual adapter unregistered — FallbacksChain/Auto hidden from the model picker");
769
+ logger.info("llm-fallbacks: virtual adapter unregistered — FallbacksChain rows hidden from the model picker");
771
770
  }
772
771
  };
773
772
  ctx.inject(["llm"], (llmCtx) => {
@@ -786,6 +785,7 @@ function installFallbacksAdapter(ctx, readConfig, walk) {
786
785
  //#region .build/host/chain-walk.js
787
786
  const EMPTY_EFFECTIVE_CHAIN_CODE = "EMPTY_EFFECTIVE_CHAIN";
788
787
  const UNDISPATCHABLE_HEAD_CODE = "UNDISPATCHABLE_EFFECTIVE_HEAD";
788
+ const UNKNOWN_CHAIN_CODE = "UNKNOWN_CHAIN";
789
789
  const LLM_UNAVAILABLE_CODE = "LLM_UNAVAILABLE";
790
790
  const REQUEST_SCOPED_CODES = new Set(["CONTEXT_WINDOW_EXCEEDED"]);
791
791
  async function makeModelExists(catalog, providers) {
@@ -820,6 +820,64 @@ async function makeContextWindowOf(catalog, routes) {
820
820
  }));
821
821
  return (provider, model) => windows.get(selectorKey(provider, model));
822
822
  }
823
+ function makeModelInfoOf(catalog) {
824
+ const resolveModelInfo = catalog?.resolveModelInfo?.bind(catalog);
825
+ if (resolveModelInfo === void 0) return () => Promise.resolve(void 0);
826
+ const cache = new Map();
827
+ return (provider, model) => {
828
+ const key = selectorKey(provider, model);
829
+ let info = cache.get(key);
830
+ if (info === void 0) {
831
+ info = (async () => {
832
+ try {
833
+ return await resolveModelInfo(provider, model);
834
+ } catch {
835
+ return;
836
+ }
837
+ })();
838
+ cache.set(key, info);
839
+ }
840
+ return info;
841
+ };
842
+ }
843
+ async function adaptForTarget(options, target, modelInfoOf, debug) {
844
+ if (options.reasoningEffort === void 0 && options.maxTokens === void 0) return options;
845
+ const info = await modelInfoOf(target.provider, target.model);
846
+ if (info === void 0 || info === null) return options;
847
+ let adapted;
848
+ const dropped = [];
849
+ try {
850
+ adapted = { ...options };
851
+ const effort = options.reasoningEffort;
852
+ if (effort !== void 0 && info.reasoning !== void 0 && !(info.reasoning.efforts ?? []).some((supported) => supported.id === effort)) {
853
+ delete adapted.reasoningEffort;
854
+ dropped.push("reasoningEffort");
855
+ }
856
+ const maxTokens = options.maxTokens;
857
+ if (maxTokens !== void 0 && info.defaultMaxTokens !== void 0 && maxTokens > info.defaultMaxTokens) {
858
+ delete adapted.maxTokens;
859
+ dropped.push("maxTokens");
860
+ }
861
+ } catch {
862
+ return options;
863
+ }
864
+ if (dropped.length > 0) try {
865
+ debug?.("llm-fallbacks: parameter adaptation candidate=%s/%s dropped=%s", target.provider, target.model, dropped.join(","));
866
+ } catch {}
867
+ return adapted;
868
+ }
869
+ const MAX_TOKENS_REJECTION = /max_tokens[\s\S]{0,40}?(非法|invalid|range|范围|exceed|too\s+large)/i;
870
+ function repairableControl(failure, options) {
871
+ if (failure.code === "UNSUPPORTED_REASONING_EFFORT") return options.reasoningEffort === void 0 ? void 0 : "reasoningEffort";
872
+ if (failure.code === "INVALID_REQUEST" && options.maxTokens !== void 0 && MAX_TOKENS_REJECTION.test(failure.message)) return "maxTokens";
873
+ }
874
+ const SLOT_MARKER_SEPARATOR = "\0";
875
+ function slotMarkerKey(agentId, chainId) {
876
+ return agentId + SLOT_MARKER_SEPARATOR + chainId;
877
+ }
878
+ function slotMarkerPrefix(agentId) {
879
+ return agentId + SLOT_MARKER_SEPARATOR;
880
+ }
823
881
  function envelopeProvenance(replayState) {
824
882
  if (typeof replayState !== "object" || replayState === null) return void 0;
825
883
  const response = replayState.response;
@@ -837,7 +895,7 @@ function restoreEnvelopeProvenance(messages) {
837
895
  const restored = messages.map((message) => {
838
896
  const source = message.source;
839
897
  if (message.role !== "assistant" || source?.kind !== "model") return message;
840
- if (source.provider !== "FallbacksChain" || source.model !== "Auto") return message;
898
+ if (source.provider !== "FallbacksChain") return message;
841
899
  const provenance = envelopeProvenance(source.replayState);
842
900
  if (provenance === void 0) return message;
843
901
  changed = true;
@@ -851,37 +909,21 @@ function restoreEnvelopeProvenance(messages) {
851
909
  });
852
910
  return changed ? restored : messages;
853
911
  }
854
- function agentLikeOf(view) {
855
- return {
856
- options: view?.optionsRoute,
857
- session: { header: {
858
- origin: view?.origin ?? "root",
859
- agentPreset: view?.agentPreset
860
- } }
861
- };
862
- }
863
- function servedRouteOf(config, route, now) {
864
- if (route?.provider !== "FallbacksChain" || route.model !== "Auto") return void 0;
865
- const head = effectiveHeadOf(config, now);
866
- return head === void 0 ? void 0 : {
867
- provider: head.provider,
868
- model: head.model
869
- };
870
- }
871
912
  function slotWinnerKey(winner) {
872
913
  if (winner === "all-day") return "all-day";
873
914
  return winner.kind === "preset" ? `preset:${winner.preset}` : `custom:${winner.start}-${winner.end}`;
874
915
  }
875
916
  function logSlotRotation(deps, start) {
876
- const { config, now, origin, owner } = start;
917
+ const { config, now, chain, owner } = start;
877
918
  const markers = deps.slotWinners;
878
- if (markers === void 0 || owner === void 0 || origin !== "root") return;
879
- if (!isAllDayConforming(config.rootChain) || (config.timeSlots?.length ?? 0) === 0) return;
880
- const slot = resolveSlotState(config, now, config.tz ?? "Asia/Shanghai");
919
+ if (markers === void 0 || owner === void 0) return;
920
+ if (!isAllDayConforming(chain.entries) || (chain.timeSlots?.length ?? 0) === 0) return;
921
+ const slot = resolveSlotState(chain, now, config.tz ?? "Asia/Shanghai");
922
+ const markerKey = slotMarkerKey(owner, chain.id);
881
923
  const key = slotWinnerKey(slot.winner);
882
- const previous = markers.get(owner);
883
- if (previous !== void 0 && previous.key !== key) deps.log.info("llm-fallbacks: agent \"%s\" time-slot switch (分时切换): %s -> %s", owner, previous.label, slot.label);
884
- markers.set(owner, {
924
+ const previous = markers.get(markerKey);
925
+ if (previous !== void 0 && previous.key !== key) deps.log.info("llm-fallbacks: agent \"%s\" time-slot switch (分时切换) on chain \"%s\": %s -> %s", owner, chain.id, previous.label, slot.label);
926
+ markers.set(markerKey, {
885
927
  key,
886
928
  label: slot.label
887
929
  });
@@ -889,45 +931,33 @@ function logSlotRotation(deps, start) {
889
931
  function resolveWalk(deps, options) {
890
932
  const config = deps.readConfig();
891
933
  const now = new Date();
892
- const view = deps.resolveAgentView(options.sessionId);
893
934
  const owner = options.sessionId;
894
- const seam = owner === void 0 ? void 0 : deps.seamRecord(owner);
895
- const role = resolveDeclaredRole(agentLikeOf(view), config.roles.rules, deps.roleIds(), {
896
- dispatchRole: seam?.role,
897
- servedRoute: servedRouteOf(config, view?.optionsRoute, now),
898
- warn: deps.log.warn
899
- });
900
- const origin = view?.origin ?? "root";
901
- const rootTail = origin === "subagent" ? config.rootChain : resolveEffectiveChain(config, now, config.tz ?? "Asia/Shanghai");
902
- const entries = roleChainEntries(config.roles.list, rootTail, role);
935
+ const chain = config.chains.find((entry) => entry.id === options.model);
936
+ if (chain === void 0) throw new LlmError(`llm-fallbacks: unknown virtual chain "${String(options.model)}" — re-select a chain in the model picker`, UNKNOWN_CHAIN_CODE);
937
+ const entries = resolveEffectiveChain(chain, now, config.tz ?? "Asia/Shanghai");
903
938
  if (entries.length === 0) throw new LlmError("llm-fallbacks: the effective chain is empty — the virtual FallbacksChain route has no head to delegate to", EMPTY_EFFECTIVE_CHAIN_CODE);
904
- if (origin === "root" && !isAllDayConforming(config.rootChain)) throw new LlmError("llm-fallbacks: the all-day chain is not conforming (exactly one official model required) — refusing to delegate", UNDISPATCHABLE_HEAD_CODE);
905
- const policy = owner !== void 0 && seam?.virtualRouteInjected === true ? deps.policyFor(owner) : void 0;
906
- if (policy?.state === "unprovable") throw new LlmError("llm-fallbacks: the subagent model-selection policy is unprovable — refusing to delegate the FallbacksChain call", UNDISPATCHABLE_HEAD_CODE);
907
- const dispatchable = firstDispatchableExactHead(entries);
908
- const allowedModels = policy?.state === "enabled" ? policy.allowedModels : void 0;
909
- const head = allowedModels === void 0 ? dispatchable : firstDispatchableExactHead(entries, allowedModels);
910
- if (head === void 0) throw new LlmError(dispatchable === void 0 ? "llm-fallbacks: the effective chain head cannot be dispatched (wildcard, malformed, or self-route) — refusing to delegate" : "llm-fallbacks: every dispatchable route of the effective chain is outside the subagent allowlist — refusing to delegate", UNDISPATCHABLE_HEAD_CODE);
939
+ if (!isAllDayConforming(chain.entries)) throw new LlmError("llm-fallbacks: the chain is not conforming (exactly one official model required) — refusing to delegate", UNDISPATCHABLE_HEAD_CODE);
940
+ const head = firstDispatchableExactHead(entries);
941
+ if (head === void 0) throw new LlmError("llm-fallbacks: the effective chain head cannot be dispatched (wildcard, malformed, or self-route) — refusing to delegate", UNDISPATCHABLE_HEAD_CODE);
911
942
  const llm = deps.getLlm();
912
943
  if (llm === void 0) throw new LlmError("llm-fallbacks: the llm runtime is unavailable — cannot delegate the FallbacksChain call", LLM_UNAVAILABLE_CODE);
913
944
  return {
914
945
  config,
915
946
  now,
916
- origin,
917
- role,
918
- rootTail,
947
+ chain,
948
+ entries,
919
949
  head,
920
950
  llm,
921
- owner,
922
- seam
951
+ owner
923
952
  };
924
953
  }
925
954
  function walkChain(deps, options) {
926
955
  return walk(deps, resolveWalk(deps, options), options);
927
956
  }
928
957
  async function* walk(deps, start, options) {
929
- const { config, role, rootTail, head, llm, owner } = start;
958
+ const { config, chain, head, llm, owner } = start;
930
959
  logSlotRotation(deps, start);
960
+ const modelInfoOf = makeModelInfoOf(llm);
931
961
  const messages = restoreEnvelopeProvenance(options.messages);
932
962
  const stateOf = () => owner === void 0 ? void 0 : deps.states.peek(owner);
933
963
  const failedKeys = new Set();
@@ -946,12 +976,12 @@ async function* walk(deps, start, options) {
946
976
  failedKeys.add(selectorKey(failed.provider, failed.model));
947
977
  if (!REQUEST_SCOPED_CODES.has(code)) coolRoute(failed);
948
978
  };
949
- const advanceFrom = async (from, failureCode, blockedReason) => {
950
- const { all, wildcard } = resolveChainViews(config.roles.list, rootTail, role, from.provider, from.model, deps.log.warn);
979
+ const advanceFrom = async (from, failureCode) => {
980
+ const { all, wildcard } = resolveChainViews(start.entries, from.provider, from.model, deps.log.warn);
951
981
  const own = all.map((candidate) => candidate.provider !== FALLBACKS_PROVIDER$1);
952
982
  const candidates = all.filter((_, index) => own[index]);
953
983
  const wildcards = wildcard.filter((_, index) => own[index]);
954
- const exists = hasWildcardEntry(config.roles.list, rootTail, role) ? await makeModelExists(llm, [...new Set(candidates.map((candidate) => candidate.provider))]) : void 0;
984
+ const exists = hasWildcardEntry(start.entries) ? await makeModelExists(llm, [...new Set(candidates.map((candidate) => candidate.provider))]) : void 0;
955
985
  const contextWindowOf = failureCode === "CONTEXT_WINDOW_EXCEEDED" ? await makeContextWindowOf(llm, [from, ...candidates.flatMap((candidate) => candidate.model === void 0 ? [] : [{
956
986
  provider: candidate.provider,
957
987
  model: candidate.model
@@ -974,47 +1004,6 @@ async function* walk(deps, start, options) {
974
1004
  contextWindowOf
975
1005
  });
976
1006
  const describeCandidates = (list) => list.map(({ candidate, skip }) => skip === void 0 ? `${candidate.provider}/${candidate.model}` : `${candidate.provider}/${candidate.model} (skipped: ${skip})`);
977
- if (start.seam?.virtualRouteInjected === true && owner !== void 0) {
978
- const policy = deps.policyFor(owner);
979
- if (policy.state === "unprovable") {
980
- deps.log.warn("llm-fallbacks: agent \"%s\" subagent model-selection policy is on but unreadable — no fallback switch (a cooled-head re-entry is refused)", owner);
981
- return {
982
- candidates: describeCandidates(annotated),
983
- currentAdmitted: false
984
- };
985
- }
986
- if (policy.state === "enabled") {
987
- const currentAdmitted = routeOnAllowlist(from, policy.allowedModels);
988
- const permitted = new Set(surviving.filter((candidate) => candidate.model !== void 0 && routeOnAllowlist({
989
- provider: candidate.provider,
990
- model: candidate.model
991
- }, policy.allowedModels)).map((candidate) => selectorKey(candidate.provider, candidate.model)));
992
- const labelled = annotated.map((entry) => entry.skip === void 0 && !permitted.has(selectorKey(entry.candidate.provider, entry.candidate.model)) ? {
993
- candidate: entry.candidate,
994
- skip: "allowlist"
995
- } : entry);
996
- const allowed = firstAllowedCandidate(resolvedRoutes(surviving), policy.allowedModels);
997
- if (allowed === void 0) {
998
- const wouldBe = surviving[0];
999
- if (wouldBe?.model !== void 0) {
1000
- deps.log.warn("llm-fallbacks: agent \"%s\" fallback switch %s/%s blocked: resolved candidates are all outside the subagent allowlist — no switch", owner, wouldBe.provider, wouldBe.model);
1001
- if (blockedReason !== void 0) deps.recordBlocked?.(owner, {
1002
- provider: wouldBe.provider,
1003
- model: wouldBe.model
1004
- }, blockedReason);
1005
- }
1006
- return {
1007
- candidates: describeCandidates(labelled),
1008
- currentAdmitted
1009
- };
1010
- }
1011
- return {
1012
- target: allowed,
1013
- candidates: describeCandidates(labelled),
1014
- currentAdmitted
1015
- };
1016
- }
1017
- }
1018
1007
  const first = surviving[0];
1019
1008
  if (first?.model === void 0) return { candidates: describeCandidates(annotated) };
1020
1009
  return {
@@ -1026,111 +1015,83 @@ async function* walk(deps, start, options) {
1026
1015
  };
1027
1016
  };
1028
1017
  let target;
1029
- if (isCooled(selectorKey(head.provider, head.model))) {
1030
- const advance = await advanceFrom(head);
1031
- if (advance?.target !== void 0) target = advance.target;
1032
- else if (advance?.currentAdmitted === false) throw new LlmError("llm-fallbacks: the subagent model-selection policy no longer admits the chain head — refusing to delegate the FallbacksChain call", UNDISPATCHABLE_HEAD_CODE);
1033
- else target = head;
1034
- } else target = head;
1018
+ if (isCooled(selectorKey(head.provider, head.model))) target = (await advanceFrom(head))?.target ?? head;
1019
+ else target = head;
1035
1020
  let switches = 0;
1036
1021
  for (;;) {
1037
1022
  const key = selectorKey(target.provider, target.model);
1038
1023
  const state = stateOf();
1039
- if ((config.recovery ?? "timer") === "half-open" && state !== void 0 && state.recovery.tryMarkProbeLogged(key)) deps.log.info("llm-fallbacks: agent \"%s\" half-open probe %s/%s (role=%s, reason=%s)", owner ?? "", target.provider, target.model, role, "trigger-code");
1024
+ if ((config.recovery ?? "timer") === "half-open" && state !== void 0 && state.recovery.tryMarkProbeLogged(key)) deps.log.info("llm-fallbacks: agent \"%s\" half-open probe %s/%s (chain=%s, reason=%s)", owner ?? "", target.provider, target.model, chain.id, "trigger-code");
1040
1025
  let committed = false;
1041
1026
  let swallowed;
1042
- for await (const chunk of llm.stream({
1043
- ...options,
1044
- provider: target.provider,
1045
- model: target.model,
1046
- messages
1047
- })) {
1048
- const failure = chunk.type === "finish" && chunk.reason.kind === "error" ? chunk.reason.failure : void 0;
1049
- if (failure !== void 0 && !committed && config.triggerCodes.includes(failure.code)) {
1050
- swallowed = {
1051
- chunk,
1052
- code: failure.code
1053
- };
1054
- break;
1055
- }
1056
- if (chunk.type === "finish") {
1057
- if (failure === void 0) {
1058
- if (chunk.reason.kind !== "aborted" && state !== void 0) deps.states.observeSuccess(state, key);
1059
- } else if (config.triggerCodes.includes(failure.code)) bookFailure(target, failure.code);
1027
+ let attemptOptions = await adaptForTarget(options, target, modelInfoOf, deps.log.debug);
1028
+ const repaired = new Set();
1029
+ for (;;) {
1030
+ committed = false;
1031
+ swallowed = void 0;
1032
+ let repair;
1033
+ const pendingUsage = [];
1034
+ for await (const chunk of llm.stream({
1035
+ ...attemptOptions,
1036
+ provider: target.provider,
1037
+ model: target.model,
1038
+ messages
1039
+ })) {
1040
+ const failure = chunk.type === "finish" && chunk.reason.kind === "error" ? chunk.reason.failure : void 0;
1041
+ if (failure !== void 0 && !committed) {
1042
+ const repairable = repairableControl(failure, attemptOptions);
1043
+ if (repairable !== void 0 && !repaired.has(repairable)) {
1044
+ repair = repairable;
1045
+ break;
1046
+ }
1047
+ if (config.triggerCodes.includes(failure.code)) {
1048
+ swallowed = {
1049
+ chunk,
1050
+ code: failure.code
1051
+ };
1052
+ break;
1053
+ }
1054
+ }
1055
+ if (chunk.type === "finish") {
1056
+ if (failure === void 0) {
1057
+ if (chunk.reason.kind !== "aborted" && state !== void 0) deps.states.observeSuccess(state, key);
1058
+ } else if (config.triggerCodes.includes(failure.code)) bookFailure(target, failure.code);
1059
+ for (const pending of pendingUsage) yield pending;
1060
+ pendingUsage.length = 0;
1061
+ yield chunk;
1062
+ return;
1063
+ }
1064
+ if (chunk.type === "usage" && !committed) {
1065
+ pendingUsage.push(chunk);
1066
+ continue;
1067
+ }
1068
+ for (const pending of pendingUsage) yield pending;
1069
+ pendingUsage.length = 0;
1060
1070
  yield chunk;
1061
- return;
1071
+ if (chunk.type !== "usage") committed = true;
1072
+ }
1073
+ if (repair === void 0) {
1074
+ for (const pending of pendingUsage) yield pending;
1075
+ break;
1062
1076
  }
1063
- yield chunk;
1064
- committed = true;
1077
+ repaired.add(repair);
1078
+ attemptOptions = { ...attemptOptions };
1079
+ delete attemptOptions[repair];
1080
+ deps.log.info("llm-fallbacks: agent \"%s\" parameter repair (参数修复) candidate=%s/%s dropped=%s", owner ?? "", target.provider, target.model, repair);
1065
1081
  }
1066
1082
  if (swallowed === void 0) return;
1067
1083
  bookFailure(target, swallowed.code);
1068
- const next = switches < config.maxSwitchesPerStep ? await advanceFrom(target, swallowed.code, "trigger-code") : void 0;
1084
+ const next = switches < config.maxSwitchesPerStep ? await advanceFrom(target, swallowed.code) : void 0;
1069
1085
  if (next === void 0 || next.target === void 0) {
1070
1086
  yield swallowed.chunk;
1071
1087
  return;
1072
1088
  }
1073
1089
  switches += 1;
1074
- deps.log.info("llm-fallbacks: agent \"%s\" fallback switch (降级切换) %s/%s -> %s/%s (role=%s, reason=%s, candidates=%o)", owner ?? "", target.provider, target.model, next.target.provider, next.target.model, role, "trigger-code", next.candidates);
1090
+ deps.log.info("llm-fallbacks: agent \"%s\" fallback switch (降级切换) %s/%s -> %s/%s (chain=%s, reason=%s, candidates=%o)", owner ?? "", target.provider, target.model, next.target.provider, next.target.model, chain.id, "trigger-code", next.candidates);
1075
1091
  target = next.target;
1076
1092
  }
1077
1093
  }
1078
1094
  //#endregion
1079
- //#region .build/host/subagent-policy.js
1080
- function parseRoutes(value) {
1081
- if (!Array.isArray(value)) return void 0;
1082
- const routes = [];
1083
- for (const candidate of value) {
1084
- 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;
1085
- routes.push({
1086
- provider: candidate.provider,
1087
- model: candidate.model
1088
- });
1089
- }
1090
- return routes;
1091
- }
1092
- function policySettingsFrom(value) {
1093
- if (typeof value !== "object" || value === null) return void 0;
1094
- if (!("enabled" in value) || typeof value.enabled !== "boolean") return void 0;
1095
- const routes = "allowedModels" in value ? parseRoutes(value.allowedModels) : void 0;
1096
- if (routes === void 0) return void 0;
1097
- return {
1098
- enabled: value.enabled,
1099
- allowedModels: routes
1100
- };
1101
- }
1102
- function readSessionPolicyEvent(events) {
1103
- const event = events.find((candidate) => candidate.type === "subagent/model-selection-policy");
1104
- if (event === void 0) return {
1105
- ok: false,
1106
- present: false
1107
- };
1108
- const data = event.data;
1109
- const routes = parseRoutes(typeof data === "object" && data !== null && "allowedModels" in data ? data.allowedModels : void 0);
1110
- if (routes === void 0 || routes.length === 0) return {
1111
- ok: false,
1112
- present: true
1113
- };
1114
- return {
1115
- ok: true,
1116
- allowedModels: routes
1117
- };
1118
- }
1119
- function effectivePolicy(event, settings) {
1120
- if (event.ok) return {
1121
- state: "enabled",
1122
- allowedModels: event.allowedModels
1123
- };
1124
- if (event.present) return { state: "unprovable" };
1125
- if (settings === void 0 || settings.enabled !== true) return { state: "disabled" };
1126
- const routes = parseRoutes(settings.allowedModels);
1127
- if (routes === void 0 || routes.length === 0) return { state: "unprovable" };
1128
- return {
1129
- state: "enabled",
1130
- allowedModels: routes
1131
- };
1132
- }
1133
- //#endregion
1134
1095
  //#region .build/host/role-notice.js
1135
1096
  const ROLE_NOTICE_PLUGIN = "dsh-llm-fallbacks";
1136
1097
  const ROLE_NOTICE_SOURCE_KIND = "llm-fallbacks-role-notice";
@@ -1346,7 +1307,7 @@ function declaredExecuteAsValues(prompt) {
1346
1307
  function canonicalizeRoleReference(raw) {
1347
1308
  const trimmed = raw.trim();
1348
1309
  const id = (trimmed.startsWith("@") ? trimmed.slice(1) : trimmed).trim();
1349
- if (id === "" || id === "inherit") return void 0;
1310
+ if (id === "") return void 0;
1350
1311
  return id;
1351
1312
  }
1352
1313
  function lookupDeclaredRole(id, roleIds) {
@@ -1444,48 +1405,32 @@ function installSubagentSeam(ctx, options) {
1444
1405
  role,
1445
1406
  at,
1446
1407
  firstNoticePending: existing?.firstNoticePending ?? true,
1447
- ...existing?.personaNotApplied === true || verdict.personaMissed === true ? { personaNotApplied: true } : {},
1448
- ...existing?.virtualRouteInjected === true || verdict.virtualRouteInjected === true ? { virtualRouteInjected: true } : {}
1408
+ ...existing?.personaNotApplied === true || verdict.personaMissed === true ? { personaNotApplied: true } : {}
1449
1409
  });
1450
1410
  };
1451
- const explicitRoute = (request) => {
1452
- const provider = request?.agentOptions?.provider;
1453
- const model = request?.agentOptions?.model;
1454
- return typeof provider === "string" && provider !== "" && typeof model === "string" && model !== "" ? {
1455
- provider,
1456
- model
1457
- } : void 0;
1411
+ const hasExplicitAgentOptions = (request) => {
1412
+ const agentOptions = request?.agentOptions;
1413
+ return typeof agentOptions?.provider === "string" || typeof agentOptions?.model === "string";
1458
1414
  };
1459
- const injectRoute = (spec, role, agentId) => {
1415
+ const injectRoleChain = (spec, role) => {
1460
1416
  try {
1461
1417
  if (options.source === void 0) return void 0;
1462
- const agentOptions = spec.request?.agentOptions;
1463
- if (typeof agentOptions?.provider === "string" || typeof agentOptions?.model === "string") return void 0;
1418
+ if (hasExplicitAgentOptions(spec.request)) return void 0;
1464
1419
  const config = options.source();
1465
1420
  if (!isFallbackActive(config)) return void 0;
1466
- const policy = options.policyFor?.(agentId);
1467
- if (policy?.state === "unprovable") {
1468
- debug(`llm-fallbacks: subagent seam — model-selection policy is unprovable for "${agentId}"; not mounting the virtual chain route`);
1469
- return;
1470
- }
1471
- const chain = roleChainEntries(config.roles.list, config.rootChain, role);
1472
- const allowedModels = policy?.state === "enabled" ? policy.allowedModels : void 0;
1473
- const dispatchable = firstDispatchableExactHead(chain);
1474
- const head = allowedModels === void 0 ? dispatchable : firstDispatchableExactHead(chain, allowedModels);
1475
- if (head === void 0) {
1476
- if (allowedModels !== void 0 && dispatchable !== void 0) debug(`llm-fallbacks: subagent seam — no route of role "${role}" is on the subagent allowlist; not mounting the virtual chain route`);
1421
+ const chainId = config.roles.list.find((entry) => entry.id === role)?.chain;
1422
+ if (chainId === void 0 || chainId === "") return void 0;
1423
+ if (!config.chains.some((chain) => chain.id === chainId)) {
1424
+ debug(`llm-fallbacks: subagent seam — role "${role}" names undeclared chain "${chainId}"; the child inherits the delegating agent's route`);
1477
1425
  return;
1478
1426
  }
1479
1427
  return {
1480
- request: {
1481
- ...spec.request,
1482
- agentOptions: {
1483
- ...agentOptions,
1484
- provider: FALLBACKS_PROVIDER$1,
1485
- model: FALLBACKS_CHAIN_MODEL
1486
- }
1487
- },
1488
- head
1428
+ ...spec.request,
1429
+ agentOptions: {
1430
+ ...spec.request?.agentOptions,
1431
+ provider: FALLBACKS_PROVIDER$1,
1432
+ model: chainId
1433
+ }
1489
1434
  };
1490
1435
  } catch (error) {
1491
1436
  reportDegrade(error);
@@ -1511,7 +1456,7 @@ function installSubagentSeam(ctx, options) {
1511
1456
  const endCorrelation = (key) => {
1512
1457
  if (key !== void 0) pending.delete(key);
1513
1458
  };
1514
- const resolveRoleAtSeam = (request) => {
1459
+ const resolveSeamRole = (request) => {
1515
1460
  try {
1516
1461
  const prompt = promptTextOf(request?.prompt);
1517
1462
  if (prompt.trim() === "") return void 0;
@@ -1577,7 +1522,7 @@ function installSubagentSeam(ctx, options) {
1577
1522
  if (cached !== void 0) return cached;
1578
1523
  const wrapper = Object.create(value);
1579
1524
  wrapper.startActivation = (spec) => {
1580
- const role = resolveRoleAtSeam(spec?.request) ?? (typeof spec?.childId === "string" ? records.get(spec.childId)?.role : void 0);
1525
+ const role = resolveSeamRole(spec?.request) ?? (typeof spec?.childId === "string" ? records.get(spec.childId)?.role : void 0);
1581
1526
  if (role === void 0) return service.startActivation(spec);
1582
1527
  const at = Date.now();
1583
1528
  let effectiveSpec = spec;
@@ -1586,26 +1531,16 @@ function installSubagentSeam(ctx, options) {
1586
1531
  try {
1587
1532
  if (isLocalBackend(service, spec.provider)) {
1588
1533
  reservedId = spec.childId ?? randomUUID();
1589
- const authorized = explicitRoute(spec.request);
1590
- const injected = injectRoute(spec, role, reservedId);
1591
- effectiveSpec = injected === void 0 ? reservedId === spec.childId ? spec : {
1534
+ const stamped = spec.childId !== void 0 && records.has(spec.childId) ? void 0 : injectRoleChain(spec, role);
1535
+ effectiveSpec = stamped === void 0 ? reservedId === spec.childId ? spec : {
1592
1536
  ...spec,
1593
1537
  childId: reservedId
1594
1538
  } : {
1595
1539
  ...spec,
1596
1540
  childId: reservedId,
1597
- request: injected.request
1541
+ request: stamped
1598
1542
  };
1599
- recordChild(reservedId, role, at, { virtualRouteInjected: injected !== void 0 });
1600
- const head = injected?.head ?? authorized;
1601
- if (head !== void 0) options.chainHeads?.set(reservedId, {
1602
- at,
1603
- route: {
1604
- provider: head.provider,
1605
- model: head.model
1606
- },
1607
- source: injected === void 0 ? "authorized" : "injected"
1608
- });
1543
+ recordChild(reservedId, role, at);
1609
1544
  }
1610
1545
  } catch (error) {
1611
1546
  if (reservedId !== void 0) records.delete(reservedId);
@@ -1906,28 +1841,29 @@ var FallbackStateStore = class {
1906
1841
  //#endregion
1907
1842
  //#region .build/host/gateway.js
1908
1843
  const FALLBACKS_PROFILE_ENTRY = "llm-fallbacks";
1909
- const SUBAGENT_POLICY_SETTINGS_NS = "subagent-model-selection-settings";
1910
- const VIRTUAL_ALL_ROUTE = {
1911
- provider: "FallbacksChain",
1912
- model: "Auto"
1913
- };
1914
1844
  const CONFIG_KEYS = {
1915
1845
  triggerCodes: true,
1916
- rootChain: true,
1846
+ chains: true,
1917
1847
  roles: true,
1918
1848
  cooldownMs: true,
1919
1849
  revertPolicy: true,
1920
1850
  maxSwitchesPerStep: true,
1921
1851
  presets: true,
1922
- timeSlots: true,
1923
1852
  tz: true,
1924
1853
  recovery: true
1925
1854
  };
1926
- const ROLES_KEYS = {
1927
- list: true,
1928
- rules: true
1855
+ const ROLES_KEYS = { list: true };
1856
+ const LEGACY_KEYS = new Set([
1857
+ "enabled",
1858
+ "rootChain",
1859
+ "timeSlots"
1860
+ ]);
1861
+ const CHAIN_KEYS = {
1862
+ id: true,
1863
+ label: true,
1864
+ entries: true,
1865
+ timeSlots: true
1929
1866
  };
1930
- const LEGACY_KEYS = new Set(["enabled"]);
1931
1867
  const SLOT_ROW_KEYS = {
1932
1868
  kind: true,
1933
1869
  preset: true,
@@ -1942,12 +1878,10 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
1942
1878
  bridge;
1943
1879
  seeds;
1944
1880
  settings;
1945
- subagentPolicy;
1946
- constructor(ctx, bridge, seeds, subagentPolicy) {
1881
+ constructor(ctx, bridge, seeds) {
1947
1882
  super(ctx, "fallbacks");
1948
1883
  this.bridge = bridge;
1949
1884
  this.seeds = seeds;
1950
- this.subagentPolicy = subagentPolicy;
1951
1885
  ctx.inject(["settings"], (sctx) => {
1952
1886
  this.settings = sctx.settings;
1953
1887
  return () => {
@@ -1961,11 +1895,10 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
1961
1895
  async set(patch) {
1962
1896
  validateConfigPatch(patch);
1963
1897
  if (Object.keys(patch).length === 0) return this.readResult();
1964
- const settings = this.settings;
1965
- if (settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
1966
- const normalized = Object.fromEntries(Object.entries(patch).filter(([key, value]) => !LEGACY_KEYS.has(key) && value !== null));
1967
- if (Object.keys(normalized).length === 0) return this.readResult();
1968
- await settings.update(FALLBACKS_PROFILE_ENTRY, normalized);
1898
+ if (this.settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
1899
+ const normalized = stripLegacyForWrite(Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== null)));
1900
+ if (Object.keys(normalized).length === 0 && Object.keys(patch).length === 0) return this.readResult();
1901
+ await this.writeSection(normalized);
1969
1902
  return this.readResult();
1970
1903
  }
1971
1904
  async reset() {
@@ -1982,40 +1915,23 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
1982
1915
  outcome
1983
1916
  };
1984
1917
  }
1985
- async syncAllowlist() {
1986
- const snapshot = this.subagentPolicy;
1987
- if (snapshot === void 0) throw new Error("fallbacks: subagent policy is unavailable — allowlist cannot be synced");
1988
- const { policy } = snapshot();
1989
- if (policy.state !== "enabled") throw new Error(`fallbacks: subagent policy is ${policy.state} — allowlist cannot be synced`);
1990
- const settings = this.settings;
1991
- if (settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
1992
- const merged = mergedAllowlist(policy.allowedModels, this.bridge.source());
1993
- await settings.mutate(SUBAGENT_POLICY_SETTINGS_NS, [{
1994
- op: "set",
1995
- path: ["allowedModels"],
1996
- value: merged
1997
- }]);
1998
- return this.readResult();
1999
- }
2000
1918
  readConfig(source = this.bridge.source()) {
1919
+ const migrated = migrateLegacyConfig(source);
2001
1920
  const wire = {};
2002
1921
  for (const key of Object.keys(CONFIG_KEYS)) {
2003
- const value = source[key];
1922
+ const value = migrated[key];
2004
1923
  if (value === void 0) continue;
2005
1924
  wire[key] = key === "roles" ? normalizeRoles(value) : value;
2006
1925
  }
2007
1926
  return wire;
2008
1927
  }
2009
1928
  readResult() {
2010
- const source = this.bridge.source();
2011
- const result = {
2012
- config: this.readConfig(source),
2013
- legacyKeys: detectLegacyKeys(source),
1929
+ const raw = this.bridge.rawSource?.() ?? this.bridge.source();
1930
+ return {
1931
+ config: this.readConfig(this.bridge.source()),
1932
+ legacyKeys: detectLegacyKeys(raw),
2014
1933
  seeds: this.seeds.wireStatus(this.seedsIo())
2015
1934
  };
2016
- const projected = projectSubagentPolicy(this.subagentPolicy);
2017
- if (projected !== void 0) result.subagentPolicy = projected;
2018
- return result;
2019
1935
  }
2020
1936
  seedsIo() {
2021
1937
  const settings = this.settings;
@@ -2023,83 +1939,100 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
2023
1939
  read: () => this.bridge.source(),
2024
1940
  writeRoles: (roles) => {
2025
1941
  if (settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
2026
- return settings.update(FALLBACKS_PROFILE_ENTRY, { roles });
1942
+ return this.writeSection(stripLegacyForWrite({ roles }));
2027
1943
  }
2028
1944
  };
2029
1945
  }
1946
+ async writeSection(patch) {
1947
+ const settings = this.settings;
1948
+ if (settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
1949
+ await writeFallbacksSection(settings, patch);
1950
+ }
2030
1951
  };
2031
- function mergedAllowlist(allowedModels, source) {
2032
- const merged = [];
2033
- const seen = new Set();
2034
- const push = (route) => {
2035
- const key = `${route.provider}\0${route.model}`;
2036
- if (seen.has(key)) return;
2037
- seen.add(key);
2038
- merged.push({
2039
- provider: route.provider,
2040
- model: route.model
1952
+ async function writeFallbacksSection(settings, patch, ns = FALLBACKS_PROFILE_ENTRY) {
1953
+ const stored = storedSection(settings, ns);
1954
+ const cleaned = stripLegacyForWrite(patch);
1955
+ const patchHasRoles = cleaned["roles"] !== void 0;
1956
+ const removals = legacySectionRemovals(stored).filter((op) => !(patchHasRoles && op.path[0] === "roles"));
1957
+ const ops = [];
1958
+ const storedChains = stored?.["chains"];
1959
+ const storedHasUsableChains = Array.isArray(storedChains) && storedChains.length > 0;
1960
+ if (cleaned["chains"] === void 0 && !storedHasUsableChains && stored !== void 0 && (Object.hasOwn(stored, "rootChain") || Object.hasOwn(stored, "timeSlots"))) {
1961
+ const migrated = migrateLegacyConfig({
1962
+ ...stored,
1963
+ ...cleaned
2041
1964
  });
2042
- };
2043
- for (const route of allowedModels) push(route);
2044
- const entries = [...source.roles.list.flatMap((role) => role.chain ?? []), ...source.rootChain];
2045
- for (const entry of entries) {
2046
- let parsed;
2047
- try {
2048
- parsed = parseSelector(entry);
2049
- } catch {
2050
- continue;
2051
- }
2052
- if (parsed.model === void 0) continue;
2053
- push({
2054
- provider: parsed.provider,
2055
- model: parsed.model
1965
+ if (migrated.chains.length > 0) ops.push({
1966
+ op: "set",
1967
+ path: ["chains"],
1968
+ value: migrated.chains
2056
1969
  });
2057
1970
  }
2058
- push(VIRTUAL_ALL_ROUTE);
2059
- return merged;
1971
+ for (const [key, value] of Object.entries(cleaned)) ops.push({
1972
+ op: "set",
1973
+ path: [key],
1974
+ value
1975
+ });
1976
+ ops.push(...removals);
1977
+ if (ops.length > 0) await settings.mutate(ns, ops);
1978
+ }
1979
+ function storedSection(settings, ns) {
1980
+ const user = settings.describe().find((candidate) => candidate.ns === ns)?.user;
1981
+ if (typeof user !== "object" || user === null || Array.isArray(user)) return void 0;
1982
+ return user;
1983
+ }
1984
+ function legacySectionRemovals(section) {
1985
+ if (section === void 0) return [];
1986
+ const removals = [];
1987
+ for (const key of LEGACY_KEYS) if (Object.hasOwn(section, key)) removals.push({
1988
+ op: "unset",
1989
+ path: [key]
1990
+ });
1991
+ const roles = section["roles"];
1992
+ if (typeof roles !== "object" || roles === null || Array.isArray(roles)) return removals;
1993
+ const declared = roles;
1994
+ for (const key of ["rules", "default"]) if (Object.hasOwn(declared, key)) removals.push({
1995
+ op: "unset",
1996
+ path: ["roles", key]
1997
+ });
1998
+ if (!Array.isArray(declared["list"])) return removals;
1999
+ if (declared["list"].some((row) => typeof row === "object" && row !== null && !Array.isArray(row) && (Object.hasOwn(row, "fallback") || Array.isArray(row["chain"])))) {
2000
+ const cleanedRoles = normalizeRoles({ list: declared["list"] });
2001
+ removals.push({
2002
+ op: "set",
2003
+ path: ["roles", "list"],
2004
+ value: cleanedRoles["list"] ?? []
2005
+ });
2006
+ }
2007
+ return removals;
2060
2008
  }
2061
- function projectSubagentPolicy(snapshot) {
2062
- if (snapshot === void 0) return void 0;
2063
- let captured;
2064
- try {
2065
- captured = snapshot();
2066
- } catch {
2067
- return { state: "unprovable" };
2068
- }
2069
- const { policy, head, blockedAttempt } = captured;
2070
- if (policy.state === "disabled") return { state: "disabled" };
2071
- if (policy.state === "unprovable") return { state: "unprovable" };
2072
- const wire = {
2073
- state: "enabled",
2074
- allowedModels: policy.allowedModels.map((route) => ({
2075
- provider: route.provider,
2076
- model: route.model
2077
- }))
2078
- };
2079
- if (head !== void 0) wire.head = {
2080
- route: {
2081
- provider: head.route.provider,
2082
- model: head.route.model
2083
- },
2084
- source: head.source
2085
- };
2086
- if (blockedAttempt !== void 0) wire.blockedAttempt = {
2087
- at: blockedAttempt.at,
2088
- route: {
2089
- provider: blockedAttempt.route.provider,
2090
- model: blockedAttempt.route.model
2091
- },
2092
- reason: blockedAttempt.reason
2093
- };
2094
- return wire;
2009
+ function stripLegacyForWrite(value) {
2010
+ const cleaned = {};
2011
+ for (const [key, member] of Object.entries(value)) {
2012
+ if (LEGACY_KEYS.has(key)) continue;
2013
+ if (key === "roles") {
2014
+ const roles = normalizeRoles(member);
2015
+ if (roles !== void 0) cleaned[key] = roles;
2016
+ continue;
2017
+ }
2018
+ cleaned[key] = member;
2019
+ }
2020
+ return cleaned;
2095
2021
  }
2096
2022
  function normalizeRoles(value) {
2097
2023
  if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
2098
2024
  const roles = {};
2099
- for (const field of ["list", "rules"]) {
2100
- const member = value[field];
2101
- if (member !== void 0) roles[field] = member;
2102
- }
2025
+ const list = value["list"];
2026
+ if (Array.isArray(list)) roles["list"] = list.map((item) => {
2027
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return item;
2028
+ const row = {};
2029
+ for (const [key, member] of Object.entries(item)) {
2030
+ if (key === "fallback") continue;
2031
+ if (key === "chain" && Array.isArray(member)) continue;
2032
+ row[key] = member;
2033
+ }
2034
+ return row;
2035
+ });
2103
2036
  return roles;
2104
2037
  }
2105
2038
  function validateConfigPatch(patch) {
@@ -2112,17 +2045,28 @@ function validateConfigPatch(patch) {
2112
2045
  for (const nestedKey of Object.keys(roles)) if (!Object.hasOwn(ROLES_KEYS, nestedKey)) throw new Error(`dsh-llm-fallbacks: unknown config key "roles.${nestedKey}"`);
2113
2046
  }
2114
2047
  }
2115
- if (key === "timeSlots") {
2116
- const rows = patch[key];
2117
- if (rows !== null && rows !== void 0) validateTimeSlotsPatch(rows);
2118
- }
2119
- if (key === "rootChain") {
2120
- const chain = patch[key];
2121
- 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-v4-pro)");
2122
- }
2048
+ if (key === "chains") validateChainsPatch(patch[key]);
2123
2049
  }
2124
2050
  Config(patch);
2125
2051
  }
2052
+ function validateChainsPatch(chains) {
2053
+ if (!Array.isArray(chains)) throw new Error("dsh-llm-fallbacks: chains must be an array of named chains");
2054
+ const seenIds = new Set();
2055
+ chains.forEach((chain, index) => {
2056
+ const at = `chains[${index}]`;
2057
+ if (chain === null || typeof chain !== "object" || Array.isArray(chain)) throw new Error(`dsh-llm-fallbacks: ${at} must be a plain object`);
2058
+ const record = chain;
2059
+ for (const nestedKey of Object.keys(record)) if (!Object.hasOwn(CHAIN_KEYS, nestedKey)) throw new Error(`dsh-llm-fallbacks: unknown config key "${at}.${nestedKey}"`);
2060
+ if (typeof record.id !== "string" || !CHAIN_ID_PATTERN.test(record.id)) throw new Error(`dsh-llm-fallbacks: ${at}.id must match /^[A-Za-z0-9][A-Za-z0-9-]{0,31}$/ (got ${JSON.stringify(record.id)})`);
2061
+ if (seenIds.has(record.id)) throw new Error(`dsh-llm-fallbacks: ${at} duplicates chain id "${record.id}" — chain ids must be unique (case-sensitive)`);
2062
+ seenIds.add(record.id);
2063
+ if (typeof record.label !== "string") throw new Error(`dsh-llm-fallbacks: ${at}.label must be a string`);
2064
+ const entries = record.entries;
2065
+ if (entries !== void 0 && entries !== null && (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string"))) throw new Error(`dsh-llm-fallbacks: ${at}.entries must be a string array`);
2066
+ if (Array.isArray(entries) && entries.length > 0 && !isAllDayConforming(entries)) throw new Error(`dsh-llm-fallbacks: ${at}.entries must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-v4-pro)`);
2067
+ if (record.timeSlots !== void 0 && record.timeSlots !== null) validateTimeSlotsPatch(record.timeSlots);
2068
+ });
2069
+ }
2126
2070
  function validateTimeSlotsPatch(rows) {
2127
2071
  if (!Array.isArray(rows)) throw new Error("dsh-llm-fallbacks: timeSlots must be an array of slot rows");
2128
2072
  const seenPresets = new Set();
@@ -2207,16 +2151,6 @@ function fallbacksTypertContribution() {
2207
2151
  codec: { mode: "src-json" }
2208
2152
  }],
2209
2153
  result: { mode: "src-json" }
2210
- },
2211
- {
2212
- id: "dsh-llm-fallbacks#fallbacks/sync-allowlist",
2213
- service: "fallbacks",
2214
- namespace: "fallbacks",
2215
- method: "sync-allowlist",
2216
- implementation: "syncAllowlist",
2217
- invocation: { kind: "direct" },
2218
- parameters: [],
2219
- result: { mode: "src-json" }
2220
2154
  }
2221
2155
  ]
2222
2156
  };
@@ -2241,7 +2175,6 @@ const FALLBACKS_COMMAND_LOCALES = {
2241
2175
  origin: "会话来源",
2242
2176
  role: "角色",
2243
2177
  chain: "链",
2244
- inheritRoot: "(inherit-root)",
2245
2178
  chainNone: "未配置",
2246
2179
  slot: "分时",
2247
2180
  switches: "最近降级切换",
@@ -2260,16 +2193,16 @@ const FALLBACKS_COMMAND_LOCALES = {
2260
2193
  configEnabled: "已启用",
2261
2194
  configDisabled: "未启用",
2262
2195
  configTriggerCodes: "触发码",
2263
- configRootChain: "根链",
2196
+ configChains: "虚拟链",
2264
2197
  configEmpty: "(空)",
2265
- configTimeSlots: "分时槽",
2198
+ configChainItem: "{id}({label}): {entries}|分时槽: {slots}",
2266
2199
  configSlotPresetItem: "{preset}(chain: {n}, window {window})",
2267
2200
  configSlotPresetBare: "{preset}(chain: {n})",
2268
2201
  configSlotCustomItem: "custom {start}-{end}(chain: {n})",
2269
2202
  configTz: "时区",
2270
2203
  configRoles: "角色",
2271
- configRoleItem: "{id}(chain: {n})",
2272
- configRules: "角色规则",
2204
+ configRoleItem: "{id}(指定降级链: {chain})",
2205
+ configRoleUnspecified: "未指定(沿用 dsh 默认)",
2273
2206
  configCooldown: "冷却",
2274
2207
  configRevert: "回主策略",
2275
2208
  configMaxSwitches: "单次请求行走最大切换",
@@ -2294,7 +2227,6 @@ const FALLBACKS_COMMAND_LOCALES = {
2294
2227
  origin: "Session origin",
2295
2228
  role: "Role",
2296
2229
  chain: "Chain",
2297
- inheritRoot: " (inherit-root)",
2298
2230
  chainNone: "not configured",
2299
2231
  slot: "Time slot",
2300
2232
  switches: "Recent fallback switches",
@@ -2313,16 +2245,16 @@ const FALLBACKS_COMMAND_LOCALES = {
2313
2245
  configEnabled: "enabled",
2314
2246
  configDisabled: "disabled",
2315
2247
  configTriggerCodes: "Trigger codes",
2316
- configRootChain: "Root chain",
2248
+ configChains: "Chains",
2317
2249
  configEmpty: "(empty)",
2318
- configTimeSlots: "Time slots",
2250
+ configChainItem: "{id} ({label}): {entries} | slots: {slots}",
2319
2251
  configSlotPresetItem: "{preset} (chain: {n}, window {window})",
2320
2252
  configSlotPresetBare: "{preset} (chain: {n})",
2321
2253
  configSlotCustomItem: "custom {start}-{end} (chain: {n})",
2322
2254
  configTz: "TZ",
2323
2255
  configRoles: "Roles",
2324
- configRoleItem: "{id} (chain: {n})",
2325
- configRules: "Rules",
2256
+ configRoleItem: "{id} (specified chain: {chain})",
2257
+ configRoleUnspecified: "not specified (dsh default)",
2326
2258
  configCooldown: "Cooldown",
2327
2259
  configRevert: "Revert",
2328
2260
  configMaxSwitches: "Max switches per request walk",
@@ -2372,23 +2304,6 @@ function recentFallbacksSwitches(events, limit) {
2372
2304
  }
2373
2305
  return found;
2374
2306
  }
2375
- function resolveChainForDiagnostic(roles, rootChain, role, warn = console.warn) {
2376
- if (role.trim() === "inherit") return {
2377
- chainRole: false,
2378
- chain: rootChain,
2379
- inherit: rootChain.length > 0
2380
- };
2381
- const roleDef = roles.find((declared) => declared.id.trim() === role.trim());
2382
- if (roleDef === void 0) warn(`llm-fallbacks: unknown role "${role}" — falling back to rootChain`);
2383
- const roleChain = roleDef?.chain ?? [];
2384
- const chain = roleChain.length > 0 ? roleChain : roleDef?.fallback === "none" ? [] : rootChain;
2385
- const inherit = rootChain.length > 0 && (roleDef === void 0 || roleDef.fallback !== "none");
2386
- return {
2387
- chainRole: roleChain.length > 0,
2388
- chain,
2389
- inherit
2390
- };
2391
- }
2392
2307
  function formatSwitch(entry, t) {
2393
2308
  const from = `${entry.from.provider}/${entry.from.model}`;
2394
2309
  const to = `${entry.to.provider}/${entry.to.model}`;
@@ -2404,7 +2319,7 @@ function formatConfigList(items) {
2404
2319
  return [...items.slice(0, 5), "…"].join(", ");
2405
2320
  }
2406
2321
  function formatConfigRoles(roles, t) {
2407
- const items = roles.slice(0, 5).map((role) => t.configRoleItem.replace("{id}", role.id).replace("{n}", String(role.chainCount)));
2322
+ const items = roles.slice(0, 5).map((role) => t.configRoleItem.replace("{id}", role.id).replace("{chain}", role.chain === void 0 || role.chain === "" ? t.configRoleUnspecified : role.chain));
2408
2323
  const list = items.length === 0 ? "" : `${items.join(", ")}${roles.length > 5 ? ", …" : ""}`;
2409
2324
  return `${roles.length}${list.length === 0 ? "" : ` — ${list}`}`;
2410
2325
  }
@@ -2459,21 +2374,14 @@ function formatConfigTimeSlots(slots, t) {
2459
2374
  const list = items.length === 0 ? "" : `${items.join(", ")}${slots.length > 5 ? ", …" : ""}`;
2460
2375
  return `${slots.length}${list.length === 0 ? "" : ` — ${list}`}`;
2461
2376
  }
2462
- function formatConfigRules(rules, t) {
2463
- const items = rules.slice(0, 5).map((rule) => `${rule.provider === "" ? "*" : rule.provider}/${rule.model === "" ? "*" : rule.model} → ${rule.role}`);
2464
- const list = items.length === 0 ? "" : `${items.join(", ")}${rules.length > 5 ? ", …" : ""}`;
2465
- return `${rules.length}${list.length === 0 ? "" : ` — ${list}`}`;
2466
- }
2467
2377
  function fallbacksConfigText(summary, locale = "zh") {
2468
2378
  const t = FALLBACKS_COMMAND_LOCALES[locale];
2469
2379
  return [
2470
2380
  `${t.configTitle}: ${summary.active ? t.configEnabled : t.configDisabled}`,
2471
2381
  `${t.configTriggerCodes}: ${summary.triggerCodes.length === 0 ? t.configEmpty : formatConfigList(summary.triggerCodes)}`,
2472
- `${t.configRootChain}: ${summary.rootChain.length === 0 ? t.configEmpty : formatConfigList(summary.rootChain)}`,
2473
- `${t.configTimeSlots}: ${summary.timeSlots.length === 0 ? t.configEmpty : formatConfigTimeSlots(summary.timeSlots, t)}`,
2382
+ ...summary.chains.map((chain) => t.configChainItem.replace("{id}", chain.id).replace("{label}", chain.label).replace("{entries}", chain.entries.length === 0 ? t.configEmpty : chain.entries.join(" → ")).replace("{slots}", chain.timeSlots.length === 0 ? t.configEmpty : formatConfigTimeSlots(chain.timeSlots, t))),
2474
2383
  `${t.configTz}: ${summary.tz}`,
2475
2384
  `${t.configRoles}: ${formatConfigRoles(summary.roles, t)}`,
2476
- `${t.configRules}: ${summary.rules.length === 0 ? t.configEmpty : formatConfigRules(summary.rules, t)}`,
2477
2385
  `${t.configCooldown}: ${summary.cooldownMs} ms`,
2478
2386
  `${t.configRevert}: ${summary.revertPolicy}`,
2479
2387
  `${t.configMaxSwitches}: ${summary.maxSwitchesPerStep}`,
@@ -2487,13 +2395,13 @@ function fallbacksCommandText(snapshot, locale = "zh") {
2487
2395
  const t = FALLBACKS_COMMAND_LOCALES[locale];
2488
2396
  const lines = [t.title];
2489
2397
  lines.push(`${t.origin}: ${snapshot.origin}`);
2490
- lines.push(`${t.role}: ${snapshot.role}`);
2491
- if (snapshot.chain.length === 0) lines.push(`${t.chain}: ${t.chainNone}`);
2492
- else {
2493
- const suffix = snapshot.inherit ? t.inheritRoot : "";
2494
- lines.push(`${t.chain}: ${snapshot.chain.join(" → ")}${suffix}`);
2398
+ const roleChain = snapshot.roleChain === void 0 || snapshot.roleChain === "" ? t.configRoleUnspecified : snapshot.roleChain;
2399
+ lines.push(`${t.role}: ${snapshot.role === "" ? t.configRoleUnspecified : snapshot.role}(${roleChain})`);
2400
+ if (snapshot.chains.length === 0) lines.push(`${t.chain}: ${t.chainNone}`);
2401
+ else for (const chain of snapshot.chains) {
2402
+ const marker = chain.selected ? " ●" : "";
2403
+ lines.push(`${t.chain}${marker}: ${chain.id}(${chain.label}): ${chain.entries.length === 0 ? t.chainNone : chain.entries.join(" → ")}|${t.slot}: ${chain.slotLabel}`);
2495
2404
  }
2496
- lines.push(`${t.slot}: ${snapshot.slot.label}`);
2497
2405
  if (snapshot.switches.length === 0) lines.push(`${t.switches}: ${t.switchesNone}`);
2498
2406
  else {
2499
2407
  lines.push(`${t.switches} (${snapshot.switches.length}):`);
@@ -2588,9 +2496,7 @@ var FallbacksSeedManager = class {
2588
2496
  });
2589
2497
  outcome.applied.push(seed.id);
2590
2498
  }
2591
- const config = io.read();
2592
- const currentList = roleRows(config);
2593
- const currentRules = roleRules(config);
2499
+ const currentList = roleRows(io.read());
2594
2500
  const candidate = new Map(this.registry);
2595
2501
  if (entries.size === 0) candidate.delete(source);
2596
2502
  else candidate.set(source, {
@@ -2607,10 +2513,7 @@ var FallbacksSeedManager = class {
2607
2513
  }
2608
2514
  const newList = materialize(currentList, incoming, prior, outcome.conflicts);
2609
2515
  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)`);
2610
- const computed = {
2611
- list: newList,
2612
- rules: currentRules
2613
- };
2516
+ const computed = { list: newList };
2614
2517
  if (!deepEqual(newList, currentList)) await io.writeRoles(computed);
2615
2518
  if (entries.size === 0) this.registry.delete(source);
2616
2519
  else this.registry.set(source, {
@@ -2654,8 +2557,7 @@ var FallbacksSeedManager = class {
2654
2557
  source: seeded ? declared.source : "user"
2655
2558
  };
2656
2559
  if (seeded) effective.seedPersona = declared.persona;
2657
- if (row.chain !== void 0) effective.chain = row.chain;
2658
- if (row.fallback !== void 0) effective.fallback = row.fallback;
2560
+ if (row.chain !== void 0 && row.chain !== "") effective.chain = row.chain;
2659
2561
  return effective;
2660
2562
  }) };
2661
2563
  }
@@ -2679,9 +2581,7 @@ var FallbacksSeedManager = class {
2679
2581
  reverted: false,
2680
2582
  reason: "not-seeded"
2681
2583
  };
2682
- const config = io.read();
2683
- const rows = roleRows(config);
2684
- const rules = roleRules(config);
2584
+ const rows = roleRows(io.read());
2685
2585
  const index = rows.findIndex((row) => row.id.trim() === seedId);
2686
2586
  if (index === -1) return {
2687
2587
  reverted: false,
@@ -2695,10 +2595,7 @@ var FallbacksSeedManager = class {
2695
2595
  ...row,
2696
2596
  persona: declared.persona
2697
2597
  } : row);
2698
- await io.writeRoles({
2699
- list: nextList,
2700
- rules
2701
- });
2598
+ await io.writeRoles({ list: nextList });
2702
2599
  return {
2703
2600
  reverted: true,
2704
2601
  persona: declared.persona
@@ -2767,10 +2664,6 @@ function roleRows(config) {
2767
2664
  const list = config.roles?.list;
2768
2665
  return Array.isArray(list) ? list : [];
2769
2666
  }
2770
- function roleRules(config) {
2771
- const rules = config.roles?.rules;
2772
- return Array.isArray(rules) ? rules : [];
2773
- }
2774
2667
  function deepEqual(a, b) {
2775
2668
  if (a === b) return true;
2776
2669
  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
@@ -2919,8 +2812,8 @@ function buildFallbacksTuiSection() {
2919
2812
  ns: FALLBACKS_TUI_SECTION_NS,
2920
2813
  title: "fallbacks",
2921
2814
  descriptions: {
2922
- zh: "回退设置:降级链、分时切换、角色与高级选项(与 Web 设置卡片完全一致)。仅选中 FallbacksChain/Auto 的会话会执行降级链;选择真实模型的会话没有插件降级。",
2923
- en: "Fallback settings: degradation chains, time slots, roles, and advanced options (full parity with the web settings card). Only sessions on FallbacksChain/Auto walk a chain — a real-model selection gets no plugin fallback."
2815
+ zh: "回退设置:虚拟链(默认降级链 + 链尾 + 分时槽)、角色与高级选项(与 Web 设置卡片完全一致)。主代理需选中 FallbacksChain/<链 id> 才会执行降级链;子代理沿用派发代理的链。",
2816
+ en: "Fallback settings: named chains (default degradation chain + tail + time slots), roles, and advanced options (full parity with the web settings card). The main agent must select FallbacksChain/<chain id> to walk a chain; subagents inherit the delegating agent's chain."
2924
2817
  },
2925
2818
  groups: [
2926
2819
  {
@@ -3016,63 +2909,34 @@ function buildFallbacksTuiSection() {
3016
2909
  parse: tzParse
3017
2910
  },
3018
2911
  {
3019
- path: ["rootChain"],
3020
- label: "Root chain (JSON)",
3021
- descriptions: {
3022
- zh: "根降级链(JSON)",
3023
- en: "Root chain (JSON)"
3024
- },
3025
- hint: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-v4-pro). Pro is available in the catalog — the settings card no longer disables it.",
3026
- hintDescriptions: {
3027
- zh: "模型选择器 JSON 数组;末项必须是官方模型(deepseek-official/deepseek-flash 或 deepseek-official/deepseek-v4-pro)。Pro 已在目录中提供——设置卡中不再禁用。",
3028
- en: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-v4-pro). Pro is available in the catalog — the settings card no longer disables it."
3029
- },
3030
- group: "chain",
3031
- kind: "text",
3032
- format: jsonFieldFormat,
3033
- parse: jsonFieldParse((parsed) => ({ rootChain: parsed }))
3034
- },
3035
- {
3036
- path: ["timeSlots"],
3037
- label: "Time slots (JSON)",
2912
+ path: ["chains"],
2913
+ label: "Chains (JSON)",
3038
2914
  descriptions: {
3039
- zh: "分时切换(JSON)",
3040
- en: "Time slots (JSON)"
2915
+ zh: "虚拟链(JSON)",
2916
+ en: "Chains (JSON)"
3041
2917
  },
3042
- hint: "JSON array of slot rows (kind: preset|custom).",
2918
+ hint: "JSON array of named chains ({ id, label, entries, timeSlots }); each entries array must end with an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-v4-pro).",
3043
2919
  hintDescriptions: {
3044
- zh: "分时行 JSON 数组(kind: preset|custom)。",
3045
- en: "JSON array of slot rows (kind: preset|custom)."
2920
+ zh: "命名虚拟链 JSON 数组({ id, label, entries, timeSlots });每条 entries 末项必须是官方模型(deepseek-official/deepseek-flash 或 deepseek-official/deepseek-v4-pro)。",
2921
+ en: "JSON array of named chains ({ id, label, entries, timeSlots }); each entries array must end with an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-v4-pro)."
3046
2922
  },
3047
2923
  group: "chain",
3048
2924
  kind: "text",
3049
2925
  format: jsonFieldFormat,
3050
- parse: jsonFieldParse((parsed) => ({ timeSlots: parsed }))
2926
+ parse: jsonFieldParse((parsed) => ({ chains: parsed }))
3051
2927
  },
3052
2928
  {
3053
2929
  path: ["roles", "list"],
3054
2930
  label: "Roles (JSON)",
3055
2931
  descriptions: {
3056
- zh: "角色列表(JSON)",
3057
- en: "Roles (JSON)"
2932
+ zh: "角色列表(JSON,chain 为指定降级链指针)",
2933
+ en: "Roles (JSON; chain is the specified-chain pointer)"
3058
2934
  },
3059
2935
  group: "roles",
3060
2936
  kind: "text",
3061
2937
  format: jsonFieldFormat,
3062
2938
  parse: jsonFieldParse((parsed) => ({ roles: { list: parsed } }))
3063
2939
  },
3064
- {
3065
- path: ["roles", "rules"],
3066
- label: "Role rules (JSON)",
3067
- descriptions: {
3068
- zh: "角色规则(JSON)",
3069
- en: "Role rules (JSON)"
3070
- },
3071
- group: "roles",
3072
- kind: "text",
3073
- format: jsonFieldFormat,
3074
- parse: jsonFieldParse((parsed) => ({ roles: { rules: parsed } }))
3075
- },
3076
2940
  {
3077
2941
  path: ["cooldownMs"],
3078
2942
  label: "Cooldown (ms)",
@@ -3144,65 +3008,6 @@ const stateStores = new WeakMap();
3144
3008
  function stateStore(ctx) {
3145
3009
  return stateStores.get(ctx);
3146
3010
  }
3147
- const blockedAttemptStores = new WeakMap();
3148
- function blockedAttempts(ctx) {
3149
- return blockedAttemptStores.get(ctx);
3150
- }
3151
- const chainHeadStores = new WeakMap();
3152
- function chainHeads(ctx) {
3153
- return chainHeadStores.get(ctx);
3154
- }
3155
- function latestByAt(map) {
3156
- let latest;
3157
- for (const value of map.values()) if (latest === void 0 || value.at >= latest.at) latest = value;
3158
- return latest;
3159
- }
3160
- function agentChainView(ctx, sessionId) {
3161
- if (sessionId === void 0) return void 0;
3162
- const agent = ctx.get("agents")?.get(sessionId);
3163
- if (agent === void 0) return void 0;
3164
- const header = agent.session?.header;
3165
- const route = agent.options;
3166
- return {
3167
- origin: header?.origin ?? "root",
3168
- ...header?.agentPreset === void 0 ? {} : { agentPreset: header.agentPreset },
3169
- ...typeof route?.provider === "string" && typeof route?.model === "string" ? { optionsRoute: {
3170
- provider: route.provider,
3171
- model: route.model
3172
- } } : {},
3173
- sessionId
3174
- };
3175
- }
3176
- function readSubagentSettings(ctx) {
3177
- const settings = ctx.get("settings");
3178
- if (settings === void 0) return void 0;
3179
- const descriptor = settings.describe().find((d) => d.ns === SUBAGENT_POLICY_SETTINGS_NS);
3180
- if (descriptor === void 0) return void 0;
3181
- const policy = policySettingsFrom(descriptor.value);
3182
- if (policy === void 0) throw new Error("subagent model-selection settings are malformed");
3183
- return policy;
3184
- }
3185
- function readGuardedPolicySettings(ctx, agentId, lastKnown, warn) {
3186
- try {
3187
- const settings = readSubagentSettings(ctx);
3188
- if (settings === void 0) return {
3189
- ok: true,
3190
- settings: lastKnown.get(agentId)
3191
- };
3192
- lastKnown.set(agentId, settings);
3193
- return {
3194
- ok: true,
3195
- settings
3196
- };
3197
- } catch (error) {
3198
- warn("llm-fallbacks: agent \"%s\" subagent model-selection settings read failed — %s", agentId, error?.message ?? String(error));
3199
- const known = lastKnown.get(agentId);
3200
- return known === void 0 ? { ok: false } : {
3201
- ok: true,
3202
- settings: known
3203
- };
3204
- }
3205
- }
3206
3011
  function isLiveConfigRef(value) {
3207
3012
  return typeof value === "object" && value !== null && typeof value.get === "function";
3208
3013
  }
@@ -3230,12 +3035,11 @@ function apply(ctx, config = defaultFallbacksConfig) {
3230
3035
  throw new Error(seedsSettingsUnavailable);
3231
3036
  };
3232
3037
  ctx.inject(["settings"], (sctx) => {
3233
- writeRoles = (roles) => sctx.settings.update(FALLBACKS_PROFILE_ENTRY, { roles });
3038
+ writeRoles = (roles) => writeFallbacksSection(sctx.settings, { roles });
3234
3039
  try {
3235
3040
  sctx.provide("llm-fallbacks", {
3236
3041
  name: "llm-fallbacks",
3237
3042
  version,
3238
- resolveRole,
3239
3043
  resolveChain,
3240
3044
  validateFallbacksConfig,
3241
3045
  detectLegacyKeys,
@@ -3262,14 +3066,15 @@ function apply(ctx, config = defaultFallbacksConfig) {
3262
3066
  };
3263
3067
  let serviceOwned = ctx.get("llm-fallbacks") === void 0;
3264
3068
  let settingsComposed;
3265
- let source = () => {
3069
+ const rawSource = () => {
3266
3070
  if (isLiveConfigRef(config)) return config.get();
3267
3071
  if (settingsComposed !== void 0) return settingsComposed;
3268
3072
  return entry;
3269
3073
  };
3270
- validateFallbacksConfig(entry, logger);
3271
- const legacyKeys = detectLegacyKeys(source());
3272
- 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);
3074
+ const source = () => migrateLegacyConfig(rawSource());
3075
+ validateFallbacksConfig(migrateLegacyConfig(entry), logger);
3076
+ const legacyKeys = detectLegacyKeys(rawSource());
3077
+ if (legacyKeys.length > 0) logger.warn("llm-fallbacks: legacy config keys detected (v1 two-block keys); see docs/configuration.md migration table — %o", legacyKeys);
3273
3078
  let roleIds = new Map(entry.roles.list.map((role) => [role.id.trim(), role.id]));
3274
3079
  ctx.inject(["settings"], (sctx) => {
3275
3080
  const settings = sctx.settings;
@@ -3291,23 +3096,14 @@ function apply(ctx, config = defaultFallbacksConfig) {
3291
3096
  settingsComposed = void 0;
3292
3097
  };
3293
3098
  });
3294
- const bridge = { source: () => source() };
3295
- const chainHeadMap = new Map();
3296
- chainHeadStores.set(ctx, chainHeadMap);
3297
- const blockedAttemptMap = new Map();
3298
- blockedAttemptStores.set(ctx, blockedAttemptMap);
3299
- const lastKnownPolicySettings = new Map();
3300
- const policyFor = (sessionId) => {
3301
- const settingsRead = readGuardedPolicySettings(ctx, sessionId, lastKnownPolicySettings, logger.warn);
3302
- if (!settingsRead.ok) return { state: "unprovable" };
3303
- return effectivePolicy(readSessionPolicyEvent(ctx.get("agents")?.get(sessionId)?.session.snapshotEvents() ?? []), settingsRead.settings);
3099
+ const bridge = {
3100
+ source: () => source(),
3101
+ rawSource: () => rawSource()
3304
3102
  };
3305
3103
  const subagentSeam = installSubagentSeam(ctx, {
3306
3104
  roleIds: () => roleIds,
3307
3105
  roles: () => source().roles.list,
3308
3106
  source: () => source(),
3309
- policyFor,
3310
- chainHeads: chainHeadMap,
3311
3107
  debug: (message) => logger.debug(message)
3312
3108
  });
3313
3109
  if (!subagentSeam.ownsWrapper) ctx.logger("llm-fallbacks").debug("subagent role seam wrapper already installed on this context root — this fiber registers the notice emitter and the projection unit only (multi-fiber dedupe)");
@@ -3317,20 +3113,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
3317
3113
  debug: (message) => logger.debug(message)
3318
3114
  });
3319
3115
  try {
3320
- new FallbacksConfigGateway(ctx, bridge, seeds, () => {
3321
- try {
3322
- return {
3323
- policy: effectivePolicy({
3324
- ok: false,
3325
- present: false
3326
- }, readSubagentSettings(ctx)),
3327
- head: latestByAt(chainHeadMap),
3328
- blockedAttempt: latestByAt(blockedAttemptMap)
3329
- };
3330
- } catch {
3331
- return { policy: { state: "unprovable" } };
3332
- }
3333
- });
3116
+ new FallbacksConfigGateway(ctx, bridge, seeds);
3334
3117
  } catch (error) {
3335
3118
  if (!(error instanceof Error) || !error.message.includes("has been registered")) throw error;
3336
3119
  ctx.logger("llm-fallbacks").debug("fallbacks gateway already registered — no gateway on this fiber (multi-fiber dedupe)");
@@ -3350,49 +3133,44 @@ function apply(ctx, config = defaultFallbacksConfig) {
3350
3133
  const walkDeps = {
3351
3134
  readConfig: () => source(),
3352
3135
  getLlm: () => ctx.get("llm"),
3353
- resolveAgentView: (sessionId) => agentChainView(ctx, sessionId),
3354
3136
  states,
3355
- roleIds: () => roleIds,
3356
- seamRecord: (sessionId) => subagentSeam.records.get(sessionId),
3357
- policyFor,
3358
- recordBlocked: (sessionId, route, reason) => {
3359
- blockedAttemptMap.set(sessionId, {
3360
- at: Date.now(),
3361
- route,
3362
- reason
3363
- });
3364
- },
3365
3137
  slotWinners,
3366
3138
  log: logger
3367
3139
  };
3368
3140
  const reconcileFallbacksAdapter = installFallbacksAdapter(ctx, () => source(), (options) => walkChain(walkDeps, options));
3369
3141
  ctx.on("agent/disposed", ({ agent }) => {
3370
3142
  states.delete(agent.id);
3371
- slotWinners.delete(agent.id);
3372
- blockedAttemptMap.delete(agent.id);
3373
- chainHeadMap.delete(agent.id);
3143
+ const markerPrefix = slotMarkerPrefix(agent.id);
3144
+ for (const key of slotWinners.keys()) if (key.startsWith(markerPrefix)) slotWinners.delete(key);
3374
3145
  subagentSeam.records.delete(agent.id);
3375
- lastKnownPolicySettings.delete(agent.id);
3376
3146
  });
3377
3147
  ctx.effect(() => () => {
3378
3148
  states.clear();
3379
3149
  slotWinners.clear();
3380
- blockedAttemptMap.clear();
3381
- chainHeadMap.clear();
3382
- lastKnownPolicySettings.clear();
3383
3150
  }, "llm-fallbacks: clear per-agent state");
3384
3151
  const fallbacksCommandController = {
3385
3152
  getSnapshot(agent) {
3386
3153
  const config = source();
3387
- const role = subagentSeam.records.get(agent.id)?.role ?? resolveRole(agent, config.roles.rules, roleIds, logger.warn, servedRouteOf(config, agent.options, new Date()));
3388
3154
  const state = states.peek(agent.id);
3389
3155
  const recovery = config.recovery ?? "timer";
3390
3156
  if (state !== void 0 && recovery === "half-open") states.syncRecovery(state, Date.now(), recovery);
3157
+ const roleRecord = subagentSeam.records.get(agent.id);
3158
+ const role = roleRecord?.role ?? "";
3159
+ const roleChain = roleRecord === void 0 ? void 0 : source().roles.list.find((entry) => entry.id === role)?.chain;
3160
+ const selectedChainId = agent.options?.provider === "FallbacksChain" ? agent.options?.model : void 0;
3161
+ const now = new Date();
3162
+ const chains = source().chains.map((chain) => ({
3163
+ id: chain.id,
3164
+ label: chain.label,
3165
+ entries: chain.entries,
3166
+ slotLabel: resolveSlotState(chain, now, source().tz ?? "Asia/Shanghai").label,
3167
+ selected: chain.id === selectedChainId
3168
+ }));
3391
3169
  return {
3392
3170
  origin: agent.session.header?.origin ?? "root",
3393
3171
  role,
3394
- ...resolveChainForDiagnostic(config.roles.list, config.rootChain, role, logger.warn),
3395
- slot: resolveSlotState(config, new Date(), config.tz ?? "Asia/Shanghai"),
3172
+ ...roleChain === void 0 ? {} : { roleChain },
3173
+ chains,
3396
3174
  switches: recentFallbacksSwitches(agent.session.snapshotEvents(), 5),
3397
3175
  cooldown: state === void 0 ? [] : recovery === "half-open" ? [...state.cooldown.snapshot(), ...state.recovery.halfOpenEntries().map((entry) => ({
3398
3176
  key: entry.key,
@@ -3406,25 +3184,24 @@ function apply(ctx, config = defaultFallbacksConfig) {
3406
3184
  return {
3407
3185
  active: isFallbackActive(config),
3408
3186
  triggerCodes: config.triggerCodes,
3409
- rootChain: config.rootChain,
3410
- timeSlots: (config.timeSlots ?? []).map((row) => row.kind === "preset" ? {
3411
- preset: row.preset,
3412
- chainCount: (row.chain ?? []).length
3413
- } : {
3414
- start: row.start,
3415
- end: row.end,
3416
- days: row.days,
3417
- chainCount: (row.chain ?? []).length
3418
- }),
3187
+ chains: config.chains.map((chain) => ({
3188
+ id: chain.id,
3189
+ label: chain.label,
3190
+ entries: Array.isArray(chain.entries) ? chain.entries : [],
3191
+ timeSlots: (chain.timeSlots ?? []).map((row) => row.kind === "preset" ? {
3192
+ preset: row.preset,
3193
+ chainCount: (row.chain ?? []).length
3194
+ } : {
3195
+ start: row.start,
3196
+ end: row.end,
3197
+ days: row.days,
3198
+ chainCount: (row.chain ?? []).length
3199
+ })
3200
+ })),
3419
3201
  tz: config.tz ?? "Asia/Shanghai",
3420
3202
  roles: config.roles.list.map((role) => ({
3421
3203
  id: role.id,
3422
- chainCount: role.chain?.length ?? 0
3423
- })),
3424
- rules: config.roles.rules.map((rule) => ({
3425
- provider: rule.provider ?? "",
3426
- model: rule.model ?? "",
3427
- role: rule.role
3204
+ chain: role.chain
3428
3205
  })),
3429
3206
  cooldownMs: config.cooldownMs,
3430
3207
  revertPolicy: config.revertPolicy,
@@ -3455,4 +3232,4 @@ function apply(ctx, config = defaultFallbacksConfig) {
3455
3232
  });
3456
3233
  }
3457
3234
  //#endregion
3458
- export { Config, FallbacksSeedManager, INHERIT_ROLE_ID, ROLE_ID_PATTERN, ROLE_PROJECTION_KEY, SelectorError, annotateCandidates, apply, blockedAttempts, chainHeads, createCandidateFilter, defaultFallbacksConfig, detectLegacyKeys, hasWildcardEntry, name, parseSelector, presetRoles, provide, readRoleProjectionValue, resolveCandidate, resolveChain, resolveChainViews, resolveRole, selectCandidates, stateStore, validateFallbacksConfig };
3235
+ export { CHAIN_ID_PATTERN, Config, FallbacksSeedManager, INHERIT_ROLE_ID, ROLE_ID_PATTERN, ROLE_PROJECTION_KEY, SelectorError, annotateCandidates, apply, createCandidateFilter, defaultFallbacksConfig, detectLegacyKeys, hasWildcardEntry, name, parseSelector, presetRoles, provide, readRoleProjectionValue, resolveCandidate, resolveChain, resolveChainViews, selectCandidates, stateStore, validateFallbacksConfig };