dsh-llm-fallbacks 0.6.13 → 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,33 +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
- alwaysModeRetryCap: 5,
342
339
  presets: "bundled",
343
- roleAutoMatch: true,
344
- timeSlots: [],
345
340
  tz: "Asia/Shanghai",
346
341
  recovery: "timer"
347
342
  };
343
+ const ROLE_ID_PATTERN = /^[a-z0-9-]{1,32}$/;
348
344
  const INHERIT_ROLE_ID = "inherit";
349
345
  function isFallbackActive(config) {
350
- return config.rootChain.length > 0 || (config.timeSlots?.length ?? 0) > 0 || config.roles.list.length > 0;
346
+ return config.chains.length > 0;
351
347
  }
352
- const ROLE_ID_PATTERN = /^[a-z0-9-]{1,32}$/;
353
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
+ }
354
378
  const declaredIds = new Set();
355
379
  for (const role of config.roles.list) {
356
380
  const id = role.id.trim();
@@ -358,201 +382,153 @@ function validateFallbacksConfig(config, logger) {
358
382
  if (id === "inherit") logger.warn(`llm-fallbacks: role id "${role.id}" is reserved — "inherit" cannot be declared in roles.list`);
359
383
  if (declaredIds.has(id)) logger.warn(`llm-fallbacks: duplicate role id "${role.id}" — role ids must be unique`);
360
384
  declaredIds.add(id);
361
- for (const entry of role.chain ?? []) try {
362
- parseSelector(entry);
363
- } catch (error) {
364
- logger.warn(`llm-fallbacks: ignoring invalid chain entry "${entry}" in role "${role.id}": ${error.message}`);
365
- }
366
- 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`);
367
- 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"`);
368
- }
369
- 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");
370
- for (const entry of config.rootChain) try {
371
- parseSelector(entry);
372
- } catch (error) {
373
- logger.warn(`llm-fallbacks: ignoring invalid rootChain entry "${entry}": ${error.message}`);
374
- }
375
- const validTargets = new Set([...declaredIds, INHERIT_ROLE_ID]);
376
- 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"`);
377
- if (Array.isArray(config.timeSlots)) {
378
- const seenSlotPresets = new Set();
379
- for (const row of config.timeSlots) {
380
- if (row.kind !== "preset") continue;
381
- const preset = row.preset;
382
- if (typeof preset !== "string" || !Object.hasOwn(PRESETS, preset)) {
383
- logger.warn(`llm-fallbacks: unknown time-slot preset ${JSON.stringify(preset)} — row ignored`);
384
- continue;
385
- }
386
- if (seenSlotPresets.has(preset)) {
387
- logger.warn(`llm-fallbacks: duplicate time-slot preset "${preset}" — only the first row takes effect`);
388
- continue;
389
- }
390
- seenSlotPresets.add(preset);
391
- 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`);
392
- }
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`);
393
386
  }
394
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`);
395
388
  }
396
389
  function detectLegacyKeys(source) {
397
- const keys = [];
398
- if (Object.hasOwn(source, "chains")) keys.push("chains");
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
- alwaysModeRetryCap: z.number().default(5),
451
528
  presets: z.union([z.const("bundled"), z.const("none")]).default("bundled"),
452
- roleAutoMatch: z.boolean().default(true),
453
- timeSlots: z.array(z.object({
454
- kind: z.string(),
455
- preset: z.string(),
456
- start: z.string(),
457
- end: z.string(),
458
- days: z.array(z.number()),
459
- chain: z.array(z.string())
460
- })).default([]),
461
529
  tz: z.string().default("Asia/Shanghai"),
462
530
  recovery: z.union([z.const("timer"), z.const("half-open")]).default("timer")
463
531
  }).extra("volatile", true);
464
- function buildAutomatchPrompt(roles, agent) {
465
- const lines = [
466
- "You are assigning a subagent to its best-fit role from a declared taxonomy.",
467
- "",
468
- "Declared roles:",
469
- ...roles.map((role) => `- ${role.id.trim()}: ${role.persona}`),
470
- "",
471
- `Agent context — origin: ${agent.session?.header?.origin ?? "root"}`
472
- ];
473
- const preset = agent.session?.header?.agentPreset?.trim();
474
- if (preset !== void 0 && preset !== "") lines.push(`agentPreset: ${preset}`);
475
- lines.push("", "Reply with EXACTLY ONE of the declared role ids above, or the literal \"none\" when no role fits.", "Do not add any explanation, punctuation, or other text.");
476
- return lines.join("\n");
477
- }
478
- function parseAutomatchAnswer(answer, roleIds) {
479
- const cleaned = answer.replace(/^[^a-zA-Z0-9-]+/, "").replace(/[^a-zA-Z0-9-]+$/, "");
480
- if (cleaned === "") return null;
481
- const key = cleaned.toLowerCase();
482
- if (key === "none") return null;
483
- return roleIds.get(key) ?? null;
484
- }
485
- async function pickRoleByLlm(ctx, roles, agent, opts) {
486
- try {
487
- const list = Array.isArray(roles.list) ? roles.list : [];
488
- if (list.length === 0) return null;
489
- const llm = ctx.get("llm");
490
- if (llm === void 0) return null;
491
- const route = pickJudgmentRoute(list, agent);
492
- if (route === null) return null;
493
- const roleIds = new Map(list.map((role) => [role.id.trim(), role.id]));
494
- const answer = await streamRoleAnswer(llm, route, buildAutomatchPrompt(list, agent), opts);
495
- if (answer === null) return null;
496
- return parseAutomatchAnswer(answer, roleIds);
497
- } catch (error) {
498
- opts.warn(`llm-fallbacks: auto-match failed — falling back to "inherit": ${describeError(error)}`);
499
- return null;
500
- }
501
- }
502
- function pickJudgmentRoute(roles, agent) {
503
- for (const role of roles) for (const entry of role.chain ?? []) try {
504
- const selector = parseSelector(entry);
505
- if (selector.model !== void 0) return {
506
- provider: selector.provider,
507
- model: selector.model
508
- };
509
- } catch {}
510
- const provider = agent.options?.provider;
511
- const model = agent.options?.model;
512
- if (provider !== void 0 && model !== void 0) return {
513
- provider,
514
- model
515
- };
516
- return null;
517
- }
518
- async function streamRoleAnswer(llm, route, prompt, opts) {
519
- const controller = new AbortController();
520
- const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 5e3);
521
- let text = "";
522
- let finishKind;
523
- try {
524
- for await (const chunk of llm.stream({
525
- provider: route.provider,
526
- model: route.model,
527
- system: prompt,
528
- messages: [createUserMessage({
529
- content: [{
530
- type: "text",
531
- text: "Pick the best-fit role for this agent."
532
- }],
533
- source: { kind: "user" }
534
- })],
535
- maxTokens: 32,
536
- signal: controller.signal
537
- })) if (chunk.type === "text-delta") text += chunk.text;
538
- else if (chunk.type === "finish") {
539
- finishKind = chunk.reason.kind;
540
- break;
541
- }
542
- if (controller.signal.aborted) return null;
543
- if (finishKind === "error" || finishKind === "aborted") return null;
544
- return text;
545
- } catch (error) {
546
- if (controller.signal.aborted) opts.warn("llm-fallbacks: auto-match timed out — falling back to \"inherit\"");
547
- else opts.warn(`llm-fallbacks: auto-match call failed — falling back to "inherit": ${describeError(error)}`);
548
- return null;
549
- } finally {
550
- clearTimeout(timer);
551
- }
552
- }
553
- function describeError(error) {
554
- return error instanceof Error ? error.message : String(error);
555
- }
556
532
  //#endregion
557
533
  //#region .build/host/chains.js
558
534
  function resolveCandidate(entry, failing, modelExists) {
@@ -569,19 +545,11 @@ function resolveCandidate(entry, failing, modelExists) {
569
545
  }
570
546
  return selector;
571
547
  }
572
- function buildRoleEntries(roles, rootChain, role) {
573
- if (role.trim() === "inherit") return rootChain;
574
- const roleDef = roles.find((declared) => declared.id.trim() === role.trim());
575
- if (roleDef === void 0) return rootChain;
576
- return [...roleDef.chain ?? [], ...roleDef.fallback === "none" ? [] : rootChain];
577
- }
578
- function resolveChainViews(roles, rootChain, role, provider, model, warn = console.warn) {
548
+ function resolveChainViews(entries, provider, model, warn = console.warn) {
579
549
  const failing = {
580
550
  provider,
581
551
  model
582
552
  };
583
- const entries = buildRoleEntries(roles, rootChain, role);
584
- if (role.trim() !== "inherit" && !roles.some((declared) => declared.id.trim() === role.trim())) warn(`llm-fallbacks: unknown role "${role}" — falling back to rootChain`);
585
553
  const all = [];
586
554
  const wildcard = [];
587
555
  for (const entry of entries) {
@@ -611,12 +579,11 @@ function selectCandidates(all, wildcard, filter, modelExists) {
611
579
  }
612
580
  return surviving;
613
581
  }
614
- function resolveChain(roles, rootChain, role, provider, model, filter, modelExists, warn = console.warn) {
615
- 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);
616
584
  return selectCandidates(all, wildcard, filter, modelExists);
617
585
  }
618
- function hasWildcardEntry(roles, rootChain, role) {
619
- const entries = buildRoleEntries(roles, rootChain, role);
586
+ function hasWildcardEntry(entries) {
620
587
  for (const entry of entries) try {
621
588
  if (parseSelector(entry).model === void 0) return true;
622
589
  } catch {}
@@ -668,148 +635,463 @@ function annotateCandidates(candidates, surviving, options) {
668
635
  });
669
636
  }
670
637
  //#endregion
671
- //#region .build/host/roles.js
672
- function resolveRole(agent, rules, roleIds, warn = console.warn, servedRoute) {
673
- if ((agent.session?.header?.origin ?? "root") !== "subagent") return INHERIT_ROLE_ID;
674
- const recordedRoute = {
675
- provider: agent.options?.provider,
676
- model: agent.options?.model
677
- };
678
- for (const rule of rules) {
679
- const matchesPair = (pair) => (!rule.provider || rule.provider === pair.provider) && (!rule.model || rule.model === pair.model);
680
- if (!matchesPair(recordedRoute) && !(servedRoute !== void 0 && matchesPair(servedRoute))) continue;
681
- const target = rule.role.trim();
682
- if (target === "inherit") return INHERIT_ROLE_ID;
683
- const declared = roleIds.get(target);
684
- if (declared === void 0) {
685
- warn(`llm-fallbacks: rule references undeclared role "${rule.role}" — falling back to "inherit"`);
686
- return INHERIT_ROLE_ID;
638
+ //#region .build/host/virtual-adapter.js
639
+ const FALLBACKS_PROVIDER$1 = "FallbacksChain";
640
+ function firstDispatchableExactHead(chain) {
641
+ for (const entry of chain) {
642
+ let selector;
643
+ try {
644
+ selector = parseSelector(entry);
645
+ } catch {
646
+ continue;
687
647
  }
688
- return declared;
648
+ if (selector.model === void 0) continue;
649
+ if (selector.provider === "FallbacksChain") continue;
650
+ return {
651
+ provider: selector.provider,
652
+ model: selector.model
653
+ };
689
654
  }
690
- return INHERIT_ROLE_ID;
691
655
  }
692
- //#endregion
693
- //#region .build/host/role-resolution.js
694
- async function resolveRoleAtDispatch(agent, rules, roleIds, opts) {
695
- const preset = agent.session?.header?.agentPreset?.trim();
696
- if (preset !== void 0 && preset !== "" && preset !== "inherit") {
697
- const declared = roleIds.get(preset);
698
- if (declared !== void 0) return declared;
699
- }
700
- const dispatch = opts.dispatchRole?.trim();
701
- if (dispatch !== void 0 && dispatch !== "" && dispatch !== "inherit") {
702
- const declared = roleIds.get(dispatch);
703
- if (declared !== void 0) return declared;
704
- }
705
- const role = resolveRole(agent, rules, roleIds, opts.warn, opts.servedRoute);
706
- if (role !== "inherit") return role;
707
- if (opts.automatchEnabled && opts.automatch !== void 0) {
708
- const picked = await opts.automatch(agent);
709
- if (picked !== null && picked !== void 0) {
710
- const declared = roleIds.get(picked.trim());
711
- if (declared !== void 0) return declared;
712
- opts.warn(`llm-fallbacks: auto-match returned undeclared role "${picked}" — falling back to "inherit"`);
656
+ function effectiveHeadOf(chain, now, tz = "Asia/Shanghai") {
657
+ if (!isAllDayConforming(chain.entries)) return void 0;
658
+ return firstDispatchableExactHead(resolveEffectiveChain(chain, now, tz));
659
+ }
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);
664
+ const model = modelDisplayName !== void 0 && modelDisplayName !== "" ? modelDisplayName : head.model;
665
+ return `${chain.label}: ${model}[${slot.label}]`;
666
+ }
667
+ async function resolveHeadDisplayName(llm, head) {
668
+ if (llm === void 0) return head.model;
669
+ try {
670
+ const row = (await llm.listModels(head.provider)).find((model) => model.id === head.model);
671
+ if (row !== void 0 && row.name !== "") return row.name;
672
+ } catch {}
673
+ try {
674
+ const info = await llm.resolveModelInfo(head.provider, head.model);
675
+ if (info.name !== "") return info.name;
676
+ } catch {}
677
+ return head.model;
678
+ }
679
+ var FallbacksChainAdapter = class extends LlmAdapter {
680
+ readConfig;
681
+ getLlm;
682
+ walk;
683
+ constructor(readConfig, getLlm, walk) {
684
+ super();
685
+ this.readConfig = readConfig;
686
+ this.getLlm = getLlm;
687
+ this.walk = walk;
688
+ }
689
+ providerInfo(provider) {
690
+ return {
691
+ id: provider,
692
+ name: FALLBACKS_PROVIDER$1
693
+ };
694
+ }
695
+ async listModels(provider) {
696
+ const config = this.readConfig();
697
+ const now = new Date();
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
+ }));
708
+ }
709
+ async resolveModel(provider, model, signal) {
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");
712
+ const llm = this.getLlm();
713
+ if (head !== void 0 && llm !== void 0) try {
714
+ const info = await llm.resolveModelInfo(head.provider, head.model, signal);
715
+ return {
716
+ provider,
717
+ id: model,
718
+ name: info.name,
719
+ ...info.description === void 0 ? {} : { description: info.description },
720
+ ...info.inputModalities === void 0 ? {} : { inputModalities: info.inputModalities },
721
+ ...info.context === void 0 ? {} : { context: info.context },
722
+ ...info.defaultMaxTokens === void 0 ? {} : { defaultMaxTokens: info.defaultMaxTokens },
723
+ ...info.reasoning === void 0 ? {} : { reasoning: info.reasoning }
724
+ };
725
+ } catch {}
726
+ return {
727
+ provider,
728
+ id: model,
729
+ name: model
730
+ };
731
+ }
732
+ providerRetryPolicy(_provider) {}
733
+ imageRequestPricing(_provider, model) {
734
+ try {
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");
737
+ if (head === void 0 || head.provider === "FallbacksChain") return void 0;
738
+ return this.getLlm()?.imageRequestPricing(head.provider, head.model);
739
+ } catch {
740
+ return;
713
741
  }
714
742
  }
715
- return INHERIT_ROLE_ID;
716
- }
717
- function firstExactCandidate(all, wildcard) {
718
- for (let index = 0; index < all.length; index += 1) if (!wildcard[index]) return all[index];
743
+ stream(options) {
744
+ return this.walk(options);
745
+ }
746
+ };
747
+ function installFallbacksAdapter(ctx, readConfig, walk) {
748
+ let llm;
749
+ let disposeAdapter;
750
+ let registered = false;
751
+ const logger = ctx.logger("llm-fallbacks");
752
+ const adapter = new FallbacksChainAdapter(readConfig, () => llm, walk);
753
+ const reconcile = () => {
754
+ const shouldRegister = isFallbackActive(readConfig());
755
+ if (shouldRegister && !registered) {
756
+ if (llm === void 0) return;
757
+ try {
758
+ disposeAdapter = llm.registerAdapter([FALLBACKS_PROVIDER$1], adapter);
759
+ registered = true;
760
+ logger.info("llm-fallbacks: virtual adapter registered — FallbacksChain/<chain id> rows are selectable in the model picker");
761
+ } catch (error) {
762
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "DUPLICATE_ADAPTER") throw error;
763
+ logger.debug("llm-fallbacks: virtual adapter already registered — no route on this fiber (multi-fiber dedupe)");
764
+ }
765
+ } else if (!shouldRegister && registered) {
766
+ disposeAdapter?.();
767
+ disposeAdapter = void 0;
768
+ registered = false;
769
+ logger.info("llm-fallbacks: virtual adapter unregistered — FallbacksChain rows hidden from the model picker");
770
+ }
771
+ };
772
+ ctx.inject(["llm"], (llmCtx) => {
773
+ llm = llmCtx.llm;
774
+ reconcile();
775
+ return () => {
776
+ disposeAdapter?.();
777
+ disposeAdapter = void 0;
778
+ registered = false;
779
+ llm = void 0;
780
+ };
781
+ });
782
+ return reconcile;
719
783
  }
720
784
  //#endregion
721
- //#region .build/host/authorized-route.js
722
- function completeRoute(source) {
723
- if (typeof source !== "object" || source === null || typeof source.provider !== "string" || source.provider.length === 0 || typeof source.model !== "string" || source.model.length === 0) return;
724
- return {
725
- provider: source.provider,
726
- model: source.model
727
- };
785
+ //#region .build/host/chain-walk.js
786
+ const EMPTY_EFFECTIVE_CHAIN_CODE = "EMPTY_EFFECTIVE_CHAIN";
787
+ const UNDISPATCHABLE_HEAD_CODE = "UNDISPATCHABLE_EFFECTIVE_HEAD";
788
+ const UNKNOWN_CHAIN_CODE = "UNKNOWN_CHAIN";
789
+ const LLM_UNAVAILABLE_CODE = "LLM_UNAVAILABLE";
790
+ const REQUEST_SCOPED_CODES = new Set(["CONTEXT_WINDOW_EXCEEDED"]);
791
+ async function makeModelExists(catalog, providers) {
792
+ if (catalog === void 0 || typeof catalog.listModels !== "function") return () => true;
793
+ const models = new Map();
794
+ await Promise.all(providers.map(async (provider) => {
795
+ try {
796
+ const rows = await catalog.listModels(provider);
797
+ models.set(provider, new Set(rows.map((row) => row.id)));
798
+ } catch {
799
+ models.set(provider, new Set());
800
+ }
801
+ }));
802
+ return (provider, model) => models.get(provider)?.has(model) ?? false;
803
+ }
804
+ async function makeContextWindowOf(catalog, routes) {
805
+ if (catalog === void 0) return () => void 0;
806
+ const windows = new Map();
807
+ if (typeof catalog.listModels === "function") await Promise.all([...new Set(routes.map((route) => route.provider))].map(async (provider) => {
808
+ try {
809
+ for (const row of await catalog.listModels(provider)) if (typeof row.contextWindow === "number") windows.set(selectorKey(provider, row.id), row.contextWindow);
810
+ } catch {}
811
+ }));
812
+ const resolveModelInfo = catalog.resolveModelInfo?.bind(catalog);
813
+ if (resolveModelInfo !== void 0) await Promise.all(routes.map(async (route) => {
814
+ const key = selectorKey(route.provider, route.model);
815
+ if (windows.has(key)) return;
816
+ try {
817
+ const window = (await resolveModelInfo(route.provider, route.model))?.context?.contextWindow;
818
+ if (typeof window === "number") windows.set(key, window);
819
+ } catch {}
820
+ }));
821
+ return (provider, model) => windows.get(selectorKey(provider, model));
728
822
  }
729
- function lastModelSelection(entries) {
730
- if (entries === void 0) return void 0;
731
- for (let index = entries.length - 1; index >= 0; index -= 1) {
732
- const candidate = entries[index];
733
- if (candidate?.type !== "model/selection") continue;
734
- const route = completeRoute(candidate.data);
735
- if (route !== void 0) return route;
736
- }
737
- }
738
- function detectAuthorizedRoute(session) {
739
- const { events, requestHeader, options, inherited } = session;
740
- const selected = lastModelSelection(events);
741
- if (selected !== void 0) return selected;
742
- const route = completeRoute(requestHeader) ?? completeRoute(options);
743
- if (route === void 0) return void 0;
744
- const baseline = completeRoute(inherited);
745
- if (baseline !== void 0 && route.provider === baseline.provider && route.model === baseline.model) return;
746
- return route;
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
+ };
747
842
  }
748
- //#endregion
749
- //#region .build/host/route-allowlist.js
750
- function resolvedRoutes(candidates) {
751
- const routes = [];
752
- for (const candidate of candidates) {
753
- if (candidate.model === void 0) continue;
754
- routes.push({
755
- provider: candidate.provider,
756
- model: candidate.model
757
- });
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;
758
863
  }
759
- return routes;
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;
760
868
  }
761
- function routeOnAllowlist(route, allowedModels) {
762
- return allowedModels.some((allowed) => allowed.provider === route.provider && allowed.model === route.model);
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";
763
873
  }
764
- function firstAllowedCandidate(resolved, allowedModels) {
765
- return resolved.find((route) => routeOnAllowlist(route, allowedModels));
874
+ const SLOT_MARKER_SEPARATOR = "\0";
875
+ function slotMarkerKey(agentId, chainId) {
876
+ return agentId + SLOT_MARKER_SEPARATOR + chainId;
766
877
  }
767
- //#endregion
768
- //#region .build/host/subagent-policy.js
769
- function parseRoutes(value) {
770
- if (!Array.isArray(value)) return void 0;
771
- const routes = [];
772
- for (const candidate of value) {
773
- 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;
774
- routes.push({
775
- provider: candidate.provider,
776
- model: candidate.model
777
- });
778
- }
779
- return routes;
878
+ function slotMarkerPrefix(agentId) {
879
+ return agentId + SLOT_MARKER_SEPARATOR;
780
880
  }
781
- function readSessionPolicyEvent(events) {
782
- const event = events.find((candidate) => candidate.type === "subagent/model-selection-policy");
783
- if (event === void 0) return {
784
- ok: false,
785
- present: false
786
- };
787
- const data = event.data;
788
- const routes = parseRoutes(typeof data === "object" && data !== null && "allowedModels" in data ? data.allowedModels : void 0);
789
- if (routes === void 0 || routes.length === 0) return {
790
- ok: false,
791
- present: true
792
- };
881
+ function envelopeProvenance(replayState) {
882
+ if (typeof replayState !== "object" || replayState === null) return void 0;
883
+ const response = replayState.response;
884
+ if (typeof response !== "object" || response === null) return void 0;
885
+ const { provider, model } = response;
886
+ if (typeof provider !== "string" || provider === "" || provider === "FallbacksChain") return void 0;
887
+ if (typeof model !== "string" || model === "") return void 0;
793
888
  return {
794
- ok: true,
795
- allowedModels: routes
889
+ provider,
890
+ model
796
891
  };
797
892
  }
798
- function effectivePolicy(event, settings) {
799
- if (event.ok) return {
800
- state: "enabled",
801
- allowedModels: event.allowedModels
802
- };
803
- if (event.present) return { state: "unprovable" };
804
- if (settings === void 0 || settings.enabled !== true) return { state: "disabled" };
805
- const routes = parseRoutes(settings.allowedModels);
806
- if (routes === void 0 || routes.length === 0) return { state: "unprovable" };
807
- return {
808
- state: "enabled",
809
- allowedModels: routes
810
- };
893
+ function restoreEnvelopeProvenance(messages) {
894
+ let changed = false;
895
+ const restored = messages.map((message) => {
896
+ const source = message.source;
897
+ if (message.role !== "assistant" || source?.kind !== "model") return message;
898
+ if (source.provider !== "FallbacksChain") return message;
899
+ const provenance = envelopeProvenance(source.replayState);
900
+ if (provenance === void 0) return message;
901
+ changed = true;
902
+ return {
903
+ ...message,
904
+ source: {
905
+ ...source,
906
+ ...provenance
907
+ }
908
+ };
909
+ });
910
+ return changed ? restored : messages;
811
911
  }
812
- //#endregion
912
+ function slotWinnerKey(winner) {
913
+ if (winner === "all-day") return "all-day";
914
+ return winner.kind === "preset" ? `preset:${winner.preset}` : `custom:${winner.start}-${winner.end}`;
915
+ }
916
+ function logSlotRotation(deps, start) {
917
+ const { config, now, chain, owner } = start;
918
+ const markers = deps.slotWinners;
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);
923
+ const key = slotWinnerKey(slot.winner);
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, {
927
+ key,
928
+ label: slot.label
929
+ });
930
+ }
931
+ function resolveWalk(deps, options) {
932
+ const config = deps.readConfig();
933
+ const now = new Date();
934
+ const owner = options.sessionId;
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");
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);
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);
942
+ const llm = deps.getLlm();
943
+ if (llm === void 0) throw new LlmError("llm-fallbacks: the llm runtime is unavailable — cannot delegate the FallbacksChain call", LLM_UNAVAILABLE_CODE);
944
+ return {
945
+ config,
946
+ now,
947
+ chain,
948
+ entries,
949
+ head,
950
+ llm,
951
+ owner
952
+ };
953
+ }
954
+ function walkChain(deps, options) {
955
+ return walk(deps, resolveWalk(deps, options), options);
956
+ }
957
+ async function* walk(deps, start, options) {
958
+ const { config, chain, head, llm, owner } = start;
959
+ logSlotRotation(deps, start);
960
+ const modelInfoOf = makeModelInfoOf(llm);
961
+ const messages = restoreEnvelopeProvenance(options.messages);
962
+ const stateOf = () => owner === void 0 ? void 0 : deps.states.peek(owner);
963
+ const failedKeys = new Set();
964
+ const isCooled = (key) => {
965
+ const state = stateOf();
966
+ return state !== void 0 && deps.states.isSuppressed(state, key, Date.now(), config.recovery ?? "timer");
967
+ };
968
+ const coolRoute = (failed) => {
969
+ if (owner === void 0) return;
970
+ const state = deps.states.get(owner);
971
+ const key = selectorKey(failed.provider, failed.model);
972
+ const until = config.revertPolicy === "never" ? Number.POSITIVE_INFINITY : (config.recovery ?? "timer") === "half-open" ? Date.now() + escalatedCooldownMs(config.cooldownMs, state.recovery.recordFailure(key)) : Date.now() + config.cooldownMs;
973
+ deps.states.suppress(state, key, until);
974
+ };
975
+ const bookFailure = (failed, code) => {
976
+ failedKeys.add(selectorKey(failed.provider, failed.model));
977
+ if (!REQUEST_SCOPED_CODES.has(code)) coolRoute(failed);
978
+ };
979
+ const advanceFrom = async (from, failureCode) => {
980
+ const { all, wildcard } = resolveChainViews(start.entries, from.provider, from.model, deps.log.warn);
981
+ const own = all.map((candidate) => candidate.provider !== FALLBACKS_PROVIDER$1);
982
+ const candidates = all.filter((_, index) => own[index]);
983
+ const wildcards = wildcard.filter((_, index) => own[index]);
984
+ const exists = hasWildcardEntry(start.entries) ? await makeModelExists(llm, [...new Set(candidates.map((candidate) => candidate.provider))]) : void 0;
985
+ const contextWindowOf = failureCode === "CONTEXT_WINDOW_EXCEEDED" ? await makeContextWindowOf(llm, [from, ...candidates.flatMap((candidate) => candidate.model === void 0 ? [] : [{
986
+ provider: candidate.provider,
987
+ model: candidate.model
988
+ }])]) : void 0;
989
+ const minContextWindow = contextWindowOf?.(from.provider, from.model);
990
+ const cooldown = { isSuppressed: (key) => isCooled(key) };
991
+ const failed = { has: (key) => failedKeys.has(key) };
992
+ const surviving = selectCandidates(candidates, wildcards, createCandidateFilter({
993
+ current: from,
994
+ cooldown,
995
+ failed,
996
+ minContextWindow,
997
+ contextWindowOf
998
+ }), exists);
999
+ const annotated = annotateCandidates(candidates, surviving, {
1000
+ current: from,
1001
+ cooldown,
1002
+ failed,
1003
+ minContextWindow,
1004
+ contextWindowOf
1005
+ });
1006
+ const describeCandidates = (list) => list.map(({ candidate, skip }) => skip === void 0 ? `${candidate.provider}/${candidate.model}` : `${candidate.provider}/${candidate.model} (skipped: ${skip})`);
1007
+ const first = surviving[0];
1008
+ if (first?.model === void 0) return { candidates: describeCandidates(annotated) };
1009
+ return {
1010
+ target: {
1011
+ provider: first.provider,
1012
+ model: first.model
1013
+ },
1014
+ candidates: describeCandidates(annotated)
1015
+ };
1016
+ };
1017
+ let target;
1018
+ if (isCooled(selectorKey(head.provider, head.model))) target = (await advanceFrom(head))?.target ?? head;
1019
+ else target = head;
1020
+ let switches = 0;
1021
+ for (;;) {
1022
+ const key = selectorKey(target.provider, target.model);
1023
+ const state = stateOf();
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");
1025
+ let committed = false;
1026
+ let swallowed;
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;
1070
+ yield chunk;
1071
+ if (chunk.type !== "usage") committed = true;
1072
+ }
1073
+ if (repair === void 0) {
1074
+ for (const pending of pendingUsage) yield pending;
1075
+ break;
1076
+ }
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);
1081
+ }
1082
+ if (swallowed === void 0) return;
1083
+ bookFailure(target, swallowed.code);
1084
+ const next = switches < config.maxSwitchesPerStep ? await advanceFrom(target, swallowed.code) : void 0;
1085
+ if (next === void 0 || next.target === void 0) {
1086
+ yield swallowed.chunk;
1087
+ return;
1088
+ }
1089
+ switches += 1;
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);
1091
+ target = next.target;
1092
+ }
1093
+ }
1094
+ //#endregion
813
1095
  //#region .build/host/role-notice.js
814
1096
  const ROLE_NOTICE_PLUGIN = "dsh-llm-fallbacks";
815
1097
  const ROLE_NOTICE_SOURCE_KIND = "llm-fallbacks-role-notice";
@@ -1025,7 +1307,7 @@ function declaredExecuteAsValues(prompt) {
1025
1307
  function canonicalizeRoleReference(raw) {
1026
1308
  const trimmed = raw.trim();
1027
1309
  const id = (trimmed.startsWith("@") ? trimmed.slice(1) : trimmed).trim();
1028
- if (id === "" || id === "inherit") return void 0;
1310
+ if (id === "") return void 0;
1029
1311
  return id;
1030
1312
  }
1031
1313
  function lookupDeclaredRole(id, roleIds) {
@@ -1086,7 +1368,8 @@ function seamStateFor(root) {
1086
1368
  if (existing !== void 0) return existing;
1087
1369
  const created = {
1088
1370
  records: new Map(),
1089
- noticeEmitted: new Set()
1371
+ noticeEmitted: new Set(),
1372
+ fibers: 0
1090
1373
  };
1091
1374
  subagentSeamStates.set(root, created);
1092
1375
  return created;
@@ -1105,6 +1388,7 @@ function installSubagentSeam(ctx, options) {
1105
1388
  const pending = new Map();
1106
1389
  const wrappers = new WeakMap();
1107
1390
  const disposers = [];
1391
+ let disposed = false;
1108
1392
  let unreadableServiceLogged = false;
1109
1393
  let noRoleLogged = false;
1110
1394
  const debug = (message) => {
@@ -1115,15 +1399,44 @@ function installSubagentSeam(ctx, options) {
1115
1399
  const reportDegrade = (error) => {
1116
1400
  debug(`llm-fallbacks: subagent role seam degraded to the native path (start unaffected): ${errorMessage$1(error)}`);
1117
1401
  };
1118
- const recordChild = (childId, role, at, personaMissed) => {
1402
+ const recordChild = (childId, role, at, verdict = {}) => {
1119
1403
  const existing = records.get(childId);
1120
1404
  records.set(childId, {
1121
1405
  role,
1122
1406
  at,
1123
1407
  firstNoticePending: existing?.firstNoticePending ?? true,
1124
- ...existing?.personaNotApplied === true || personaMissed === true ? { personaNotApplied: true } : {}
1408
+ ...existing?.personaNotApplied === true || verdict.personaMissed === true ? { personaNotApplied: true } : {}
1125
1409
  });
1126
1410
  };
1411
+ const hasExplicitAgentOptions = (request) => {
1412
+ const agentOptions = request?.agentOptions;
1413
+ return typeof agentOptions?.provider === "string" || typeof agentOptions?.model === "string";
1414
+ };
1415
+ const injectRoleChain = (spec, role) => {
1416
+ try {
1417
+ if (options.source === void 0) return void 0;
1418
+ if (hasExplicitAgentOptions(spec.request)) return void 0;
1419
+ const config = options.source();
1420
+ if (!isFallbackActive(config)) return void 0;
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`);
1425
+ return;
1426
+ }
1427
+ return {
1428
+ ...spec.request,
1429
+ agentOptions: {
1430
+ ...spec.request?.agentOptions,
1431
+ provider: FALLBACKS_PROVIDER$1,
1432
+ model: chainId
1433
+ }
1434
+ };
1435
+ } catch (error) {
1436
+ reportDegrade(error);
1437
+ return;
1438
+ }
1439
+ };
1127
1440
  const beginCorrelation = (spec, role, at) => {
1128
1441
  const parentSessionId = parentSessionIdOf(spec?.request);
1129
1442
  const label = typeof spec?.label === "string" ? spec.label : void 0;
@@ -1143,7 +1456,7 @@ function installSubagentSeam(ctx, options) {
1143
1456
  const endCorrelation = (key) => {
1144
1457
  if (key !== void 0) pending.delete(key);
1145
1458
  };
1146
- const resolveRoleAtSeam = (request) => {
1459
+ const resolveSeamRole = (request) => {
1147
1460
  try {
1148
1461
  const prompt = promptTextOf(request?.prompt);
1149
1462
  if (prompt.trim() === "") return void 0;
@@ -1209,7 +1522,7 @@ function installSubagentSeam(ctx, options) {
1209
1522
  if (cached !== void 0) return cached;
1210
1523
  const wrapper = Object.create(value);
1211
1524
  wrapper.startActivation = (spec) => {
1212
- 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);
1213
1526
  if (role === void 0) return service.startActivation(spec);
1214
1527
  const at = Date.now();
1215
1528
  let effectiveSpec = spec;
@@ -1218,9 +1531,14 @@ function installSubagentSeam(ctx, options) {
1218
1531
  try {
1219
1532
  if (isLocalBackend(service, spec.provider)) {
1220
1533
  reservedId = spec.childId ?? randomUUID();
1221
- if (reservedId !== spec.childId) effectiveSpec = {
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 : {
1222
1536
  ...spec,
1223
1537
  childId: reservedId
1538
+ } : {
1539
+ ...spec,
1540
+ childId: reservedId,
1541
+ request: stamped
1224
1542
  };
1225
1543
  recordChild(reservedId, role, at);
1226
1544
  }
@@ -1252,7 +1570,7 @@ function installSubagentSeam(ctx, options) {
1252
1570
  try {
1253
1571
  observeStartResult(result, (childId) => {
1254
1572
  endCorrelation(pendingKey);
1255
- recordChild(childId, role, at, reservationPersonaMissed);
1573
+ recordChild(childId, role, at, { personaMissed: reservationPersonaMissed });
1256
1574
  }, () => {}, () => {
1257
1575
  endCorrelation(pendingKey);
1258
1576
  if (reservedId !== void 0) records.delete(reservedId);
@@ -1266,8 +1584,19 @@ function installSubagentSeam(ctx, options) {
1266
1584
  wrappers.set(value, wrapper);
1267
1585
  return wrapper;
1268
1586
  };
1587
+ const installSurface = (register) => {
1588
+ try {
1589
+ disposers.push(register());
1590
+ } catch (error) {
1591
+ for (const withdraw of disposers.splice(0)) try {
1592
+ withdraw();
1593
+ } catch {}
1594
+ if (ownsWrapper) subagentSeamRoots.delete(root);
1595
+ throw error;
1596
+ }
1597
+ };
1269
1598
  if (ownsWrapper) {
1270
- disposers.push(ctx.on(SUBAGENT_SEAM_EVENT, (_readCtx, name, _error, next) => {
1599
+ installSurface(() => ctx.on(SUBAGENT_SEAM_EVENT, (_readCtx, name, _error, next) => {
1271
1600
  const value = next();
1272
1601
  if (name !== "subagents") return value;
1273
1602
  try {
@@ -1277,7 +1606,7 @@ function installSubagentSeam(ctx, options) {
1277
1606
  return value;
1278
1607
  }
1279
1608
  }));
1280
- disposers.push(ctx.on("session/event", (session, event) => {
1609
+ installSurface(() => ctx.on("session/event", (session, event) => {
1281
1610
  try {
1282
1611
  if (event.type !== SUBAGENT_CATALOG_EVENT) return;
1283
1612
  const data = event.data;
@@ -1295,21 +1624,39 @@ function installSubagentSeam(ctx, options) {
1295
1624
  }
1296
1625
  }));
1297
1626
  }
1298
- disposers.push(installRoleNotice(ctx, {
1627
+ installSurface(() => installRoleNotice(ctx, {
1299
1628
  records,
1300
1629
  emitted: noticeEmitted,
1301
1630
  debug
1302
1631
  }));
1303
- disposers.push(installRoleProjection(ctx, { debug }));
1632
+ installSurface(() => installRoleProjection(ctx, { debug }));
1633
+ state.fibers += 1;
1634
+ const dispose = () => {
1635
+ if (disposed) return;
1636
+ disposed = true;
1637
+ if (ownsWrapper) subagentSeamRoots.delete(root);
1638
+ try {
1639
+ for (const withdraw of disposers.splice(0)) withdraw();
1640
+ } finally {
1641
+ state.fibers -= 1;
1642
+ if (state.fibers === 0) {
1643
+ state.records.clear();
1644
+ state.noticeEmitted.clear();
1645
+ }
1646
+ }
1647
+ };
1304
1648
  const seam = {
1305
1649
  records,
1306
1650
  noticeEmitted,
1307
1651
  ownsWrapper,
1308
- dispose: () => {
1309
- if (ownsWrapper) subagentSeamRoots.delete(root);
1310
- for (const dispose of disposers.splice(0)) dispose();
1311
- }
1652
+ dispose
1312
1653
  };
1654
+ try {
1655
+ ctx.effect(() => () => dispose(), "llm-fallbacks: subagent seam teardown");
1656
+ } catch (error) {
1657
+ dispose();
1658
+ throw error;
1659
+ }
1313
1660
  subagentSeamStores.set(ctx, seam);
1314
1661
  return seam;
1315
1662
  }
@@ -1440,21 +1787,6 @@ var CooldownStore = class {
1440
1787
  return active;
1441
1788
  }
1442
1789
  };
1443
- var StepFailureSet = class {
1444
- keys = new Set();
1445
- get size() {
1446
- return this.keys.size;
1447
- }
1448
- add(key) {
1449
- this.keys.add(key);
1450
- }
1451
- has(key) {
1452
- return this.keys.has(key);
1453
- }
1454
- reset() {
1455
- this.keys.clear();
1456
- }
1457
- };
1458
1790
  //#endregion
1459
1791
  //#region .build/host/state.js
1460
1792
  var FallbackStateStore = class {
@@ -1472,12 +1804,6 @@ var FallbackStateStore = class {
1472
1804
  let state = this.states.get(agentId);
1473
1805
  if (state === void 0) {
1474
1806
  state = {
1475
- stepFailures: {
1476
- turn: 0,
1477
- step: 0,
1478
- failed: new StepFailureSet(),
1479
- switchCount: 0
1480
- },
1481
1807
  cooldown: new CooldownStore(),
1482
1808
  recovery: new RecoveryStore()
1483
1809
  };
@@ -1491,42 +1817,6 @@ var FallbackStateStore = class {
1491
1817
  clear() {
1492
1818
  this.states.clear();
1493
1819
  }
1494
- syncStep(state, turn, step) {
1495
- const { stepFailures } = state;
1496
- if (stepFailures.turn === turn && stepFailures.step === step) return;
1497
- stepFailures.turn = turn;
1498
- stepFailures.step = step;
1499
- stepFailures.failed.reset();
1500
- stepFailures.switchCount = 0;
1501
- }
1502
- recordFailure(state, key) {
1503
- state.stepFailures.failed.add(key);
1504
- }
1505
- recordSwitch(state) {
1506
- state.stepFailures.switchCount += 1;
1507
- }
1508
- writePending(state, pending) {
1509
- state.pendingSwitch = pending;
1510
- state.appliedTurnStep = void 0;
1511
- }
1512
- applyPending(state, turn, step) {
1513
- const pending = state.pendingSwitch;
1514
- if (pending === void 0) return void 0;
1515
- const applied = state.appliedTurnStep;
1516
- if (applied !== void 0 && applied.turn === turn && applied.step === step) return void 0;
1517
- state.appliedTurnStep = {
1518
- turn,
1519
- step
1520
- };
1521
- state.pendingSwitch = void 0;
1522
- return pending;
1523
- }
1524
- clearStepState(state) {
1525
- state.pendingSwitch = void 0;
1526
- state.appliedTurnStep = void 0;
1527
- state.stepFailures.failed.reset();
1528
- state.stepFailures.switchCount = 0;
1529
- }
1530
1820
  suppress(state, key, untilEpochMs) {
1531
1821
  state.cooldown.suppress(key, untilEpochMs);
1532
1822
  }
@@ -1549,53 +1839,31 @@ var FallbackStateStore = class {
1549
1839
  }
1550
1840
  };
1551
1841
  //#endregion
1552
- //#region .build/host/override.js
1553
- function overrideConfigWithRouteRule(seed, to, explicitEffort) {
1554
- if (explicitEffort !== void 0) return {
1555
- ...seed,
1556
- reasoningEffort: explicitEffort,
1557
- provider: to.provider,
1558
- model: to.model
1559
- };
1560
- if (!(to.provider !== seed.provider || to.model !== seed.model)) return {
1561
- ...seed,
1562
- provider: to.provider,
1563
- model: to.model
1564
- };
1565
- const { reasoningEffort: _routeOwned, ...withoutRouteOwnedEffort } = seed;
1566
- return {
1567
- ...withoutRouteOwnedEffort,
1568
- provider: to.provider,
1569
- model: to.model
1570
- };
1571
- }
1572
- //#endregion
1573
1842
  //#region .build/host/gateway.js
1574
1843
  const FALLBACKS_PROFILE_ENTRY = "llm-fallbacks";
1575
- const SUBAGENT_POLICY_SETTINGS_NS = "subagent-model-selection-settings";
1576
- const VIRTUAL_ALL_ROUTE = {
1577
- provider: "FallbacksChain",
1578
- model: "Auto"
1579
- };
1580
1844
  const CONFIG_KEYS = {
1581
1845
  triggerCodes: true,
1582
- rootChain: true,
1846
+ chains: true,
1583
1847
  roles: true,
1584
1848
  cooldownMs: true,
1585
1849
  revertPolicy: true,
1586
1850
  maxSwitchesPerStep: true,
1587
- alwaysModeRetryCap: true,
1588
1851
  presets: true,
1589
- roleAutoMatch: true,
1590
- timeSlots: true,
1591
1852
  tz: true,
1592
1853
  recovery: true
1593
1854
  };
1594
- const ROLES_KEYS = {
1595
- list: true,
1596
- 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
1597
1866
  };
1598
- const LEGACY_KEYS = new Set(["enabled"]);
1599
1867
  const SLOT_ROW_KEYS = {
1600
1868
  kind: true,
1601
1869
  preset: true,
@@ -1610,12 +1878,10 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
1610
1878
  bridge;
1611
1879
  seeds;
1612
1880
  settings;
1613
- subagentPolicy;
1614
- constructor(ctx, bridge, seeds, subagentPolicy) {
1881
+ constructor(ctx, bridge, seeds) {
1615
1882
  super(ctx, "fallbacks");
1616
1883
  this.bridge = bridge;
1617
1884
  this.seeds = seeds;
1618
- this.subagentPolicy = subagentPolicy;
1619
1885
  ctx.inject(["settings"], (sctx) => {
1620
1886
  this.settings = sctx.settings;
1621
1887
  return () => {
@@ -1629,11 +1895,10 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
1629
1895
  async set(patch) {
1630
1896
  validateConfigPatch(patch);
1631
1897
  if (Object.keys(patch).length === 0) return this.readResult();
1632
- const settings = this.settings;
1633
- if (settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
1634
- const normalized = Object.fromEntries(Object.entries(patch).filter(([key, value]) => !LEGACY_KEYS.has(key) && value !== null));
1635
- if (Object.keys(normalized).length === 0) return this.readResult();
1636
- 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);
1637
1902
  return this.readResult();
1638
1903
  }
1639
1904
  async reset() {
@@ -1650,40 +1915,23 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
1650
1915
  outcome
1651
1916
  };
1652
1917
  }
1653
- async syncAllowlist() {
1654
- const snapshot = this.subagentPolicy;
1655
- if (snapshot === void 0) throw new Error("fallbacks: subagent policy is unavailable — allowlist cannot be synced");
1656
- const { policy } = snapshot();
1657
- if (policy.state !== "enabled") throw new Error(`fallbacks: subagent policy is ${policy.state} — allowlist cannot be synced`);
1658
- const settings = this.settings;
1659
- if (settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
1660
- const merged = mergedAllowlist(policy.allowedModels, this.bridge.source());
1661
- await settings.mutate(SUBAGENT_POLICY_SETTINGS_NS, [{
1662
- op: "set",
1663
- path: ["allowedModels"],
1664
- value: merged
1665
- }]);
1666
- return this.readResult();
1667
- }
1668
1918
  readConfig(source = this.bridge.source()) {
1919
+ const migrated = migrateLegacyConfig(source);
1669
1920
  const wire = {};
1670
1921
  for (const key of Object.keys(CONFIG_KEYS)) {
1671
- const value = source[key];
1922
+ const value = migrated[key];
1672
1923
  if (value === void 0) continue;
1673
1924
  wire[key] = key === "roles" ? normalizeRoles(value) : value;
1674
1925
  }
1675
1926
  return wire;
1676
1927
  }
1677
1928
  readResult() {
1678
- const source = this.bridge.source();
1679
- const result = {
1680
- config: this.readConfig(source),
1681
- 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),
1682
1933
  seeds: this.seeds.wireStatus(this.seedsIo())
1683
1934
  };
1684
- const projected = projectSubagentPolicy(this.subagentPolicy);
1685
- if (projected !== void 0) result.subagentPolicy = projected;
1686
- return result;
1687
1935
  }
1688
1936
  seedsIo() {
1689
1937
  const settings = this.settings;
@@ -1691,83 +1939,100 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
1691
1939
  read: () => this.bridge.source(),
1692
1940
  writeRoles: (roles) => {
1693
1941
  if (settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
1694
- return settings.update(FALLBACKS_PROFILE_ENTRY, { roles });
1942
+ return this.writeSection(stripLegacyForWrite({ roles }));
1695
1943
  }
1696
1944
  };
1697
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
+ }
1698
1951
  };
1699
- function mergedAllowlist(allowedModels, source) {
1700
- const merged = [];
1701
- const seen = new Set();
1702
- const push = (route) => {
1703
- const key = `${route.provider}\0${route.model}`;
1704
- if (seen.has(key)) return;
1705
- seen.add(key);
1706
- merged.push({
1707
- provider: route.provider,
1708
- 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
1709
1964
  });
1710
- };
1711
- for (const route of allowedModels) push(route);
1712
- const entries = [...source.roles.list.flatMap((role) => role.chain ?? []), ...source.rootChain];
1713
- for (const entry of entries) {
1714
- let parsed;
1715
- try {
1716
- parsed = parseSelector(entry);
1717
- } catch {
1718
- continue;
1719
- }
1720
- if (parsed.model === void 0) continue;
1721
- push({
1722
- provider: parsed.provider,
1723
- model: parsed.model
1965
+ if (migrated.chains.length > 0) ops.push({
1966
+ op: "set",
1967
+ path: ["chains"],
1968
+ value: migrated.chains
1724
1969
  });
1725
1970
  }
1726
- push(VIRTUAL_ALL_ROUTE);
1727
- 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;
1728
2008
  }
1729
- function projectSubagentPolicy(snapshot) {
1730
- if (snapshot === void 0) return void 0;
1731
- let captured;
1732
- try {
1733
- captured = snapshot();
1734
- } catch {
1735
- return { state: "unprovable" };
1736
- }
1737
- const { policy, head, blockedAttempt } = captured;
1738
- if (policy.state === "disabled") return { state: "disabled" };
1739
- if (policy.state === "unprovable") return { state: "unprovable" };
1740
- const wire = {
1741
- state: "enabled",
1742
- allowedModels: policy.allowedModels.map((route) => ({
1743
- provider: route.provider,
1744
- model: route.model
1745
- }))
1746
- };
1747
- if (head !== void 0) wire.head = {
1748
- route: {
1749
- provider: head.route.provider,
1750
- model: head.route.model
1751
- },
1752
- source: head.source
1753
- };
1754
- if (blockedAttempt !== void 0) wire.blockedAttempt = {
1755
- at: blockedAttempt.at,
1756
- route: {
1757
- provider: blockedAttempt.route.provider,
1758
- model: blockedAttempt.route.model
1759
- },
1760
- reason: blockedAttempt.reason
1761
- };
1762
- 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;
1763
2021
  }
1764
2022
  function normalizeRoles(value) {
1765
2023
  if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
1766
2024
  const roles = {};
1767
- for (const field of ["list", "rules"]) {
1768
- const member = value[field];
1769
- if (member !== void 0) roles[field] = member;
1770
- }
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
+ });
1771
2036
  return roles;
1772
2037
  }
1773
2038
  function validateConfigPatch(patch) {
@@ -1780,17 +2045,28 @@ function validateConfigPatch(patch) {
1780
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}"`);
1781
2046
  }
1782
2047
  }
1783
- if (key === "timeSlots") {
1784
- const rows = patch[key];
1785
- if (rows !== null && rows !== void 0) validateTimeSlotsPatch(rows);
1786
- }
1787
- if (key === "rootChain") {
1788
- const chain = patch[key];
1789
- 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)");
1790
- }
2048
+ if (key === "chains") validateChainsPatch(patch[key]);
1791
2049
  }
1792
2050
  Config(patch);
1793
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
+ }
1794
2070
  function validateTimeSlotsPatch(rows) {
1795
2071
  if (!Array.isArray(rows)) throw new Error("dsh-llm-fallbacks: timeSlots must be an array of slot rows");
1796
2072
  const seenPresets = new Set();
@@ -1875,16 +2151,6 @@ function fallbacksTypertContribution() {
1875
2151
  codec: { mode: "src-json" }
1876
2152
  }],
1877
2153
  result: { mode: "src-json" }
1878
- },
1879
- {
1880
- id: "dsh-llm-fallbacks#fallbacks/sync-allowlist",
1881
- service: "fallbacks",
1882
- namespace: "fallbacks",
1883
- method: "sync-allowlist",
1884
- implementation: "syncAllowlist",
1885
- invocation: { kind: "direct" },
1886
- parameters: [],
1887
- result: { mode: "src-json" }
1888
2154
  }
1889
2155
  ]
1890
2156
  };
@@ -1909,7 +2175,6 @@ const FALLBACKS_COMMAND_LOCALES = {
1909
2175
  origin: "会话来源",
1910
2176
  role: "角色",
1911
2177
  chain: "链",
1912
- inheritRoot: "(inherit-root)",
1913
2178
  chainNone: "未配置",
1914
2179
  slot: "分时",
1915
2180
  switches: "最近降级切换",
@@ -1928,22 +2193,20 @@ const FALLBACKS_COMMAND_LOCALES = {
1928
2193
  configEnabled: "已启用",
1929
2194
  configDisabled: "未启用",
1930
2195
  configTriggerCodes: "触发码",
1931
- configRootChain: "根链",
2196
+ configChains: "虚拟链",
1932
2197
  configEmpty: "(空)",
1933
- configTimeSlots: "分时槽",
2198
+ configChainItem: "{id}({label}): {entries}|分时槽: {slots}",
1934
2199
  configSlotPresetItem: "{preset}(chain: {n}, window {window})",
1935
2200
  configSlotPresetBare: "{preset}(chain: {n})",
1936
2201
  configSlotCustomItem: "custom {start}-{end}(chain: {n})",
1937
2202
  configTz: "时区",
1938
2203
  configRoles: "角色",
1939
- configRoleItem: "{id}(chain: {n})",
1940
- configRules: "角色规则",
2204
+ configRoleItem: "{id}(指定降级链: {chain})",
2205
+ configRoleUnspecified: "未指定(沿用 dsh 默认)",
1941
2206
  configCooldown: "冷却",
1942
2207
  configRevert: "回主策略",
1943
- configMaxSwitches: "单步最大切换",
1944
- configAlwaysCap: "always 上限",
2208
+ configMaxSwitches: "单次请求行走最大切换",
1945
2209
  configPresets: "预置",
1946
- configRoleAutoMatch: "角色自动匹配",
1947
2210
  configEdit: "编辑:/settings(TUI 设置界面)或 ~/.dsh/profiles/<profile>/cordis.patch.yml(插件行)/ $DSH_HOME/settings.yaml(fallbacks: 分节)",
1948
2211
  configEditHint: "TUI 通过 /settings 修改配置;文件编辑仍然可用",
1949
2212
  revertSeedOk: "角色 {id} 已还原为 Seed 默认",
@@ -1964,7 +2227,6 @@ const FALLBACKS_COMMAND_LOCALES = {
1964
2227
  origin: "Session origin",
1965
2228
  role: "Role",
1966
2229
  chain: "Chain",
1967
- inheritRoot: " (inherit-root)",
1968
2230
  chainNone: "not configured",
1969
2231
  slot: "Time slot",
1970
2232
  switches: "Recent fallback switches",
@@ -1983,22 +2245,20 @@ const FALLBACKS_COMMAND_LOCALES = {
1983
2245
  configEnabled: "enabled",
1984
2246
  configDisabled: "disabled",
1985
2247
  configTriggerCodes: "Trigger codes",
1986
- configRootChain: "Root chain",
2248
+ configChains: "Chains",
1987
2249
  configEmpty: "(empty)",
1988
- configTimeSlots: "Time slots",
2250
+ configChainItem: "{id} ({label}): {entries} | slots: {slots}",
1989
2251
  configSlotPresetItem: "{preset} (chain: {n}, window {window})",
1990
2252
  configSlotPresetBare: "{preset} (chain: {n})",
1991
2253
  configSlotCustomItem: "custom {start}-{end} (chain: {n})",
1992
2254
  configTz: "TZ",
1993
2255
  configRoles: "Roles",
1994
- configRoleItem: "{id} (chain: {n})",
1995
- configRules: "Rules",
2256
+ configRoleItem: "{id} (specified chain: {chain})",
2257
+ configRoleUnspecified: "not specified (dsh default)",
1996
2258
  configCooldown: "Cooldown",
1997
2259
  configRevert: "Revert",
1998
- configMaxSwitches: "Max switches/step",
1999
- configAlwaysCap: "Always-mode cap",
2260
+ configMaxSwitches: "Max switches per request walk",
2000
2261
  configPresets: "Presets",
2001
- configRoleAutoMatch: "Auto-match",
2002
2262
  configEdit: "Edit: /settings (TUI settings screen) or ~/.dsh/profiles/<profile>/cordis.patch.yml (plugin row) / $DSH_HOME/settings.yaml (fallbacks: section)",
2003
2263
  configEditHint: "TUI edits config via /settings; file editing still works",
2004
2264
  revertSeedOk: "role {id} reverted to its seed default",
@@ -2044,23 +2304,6 @@ function recentFallbacksSwitches(events, limit) {
2044
2304
  }
2045
2305
  return found;
2046
2306
  }
2047
- function resolveChainForDiagnostic(roles, rootChain, role, warn = console.warn) {
2048
- if (role.trim() === "inherit") return {
2049
- chainRole: false,
2050
- chain: rootChain,
2051
- inherit: rootChain.length > 0
2052
- };
2053
- const roleDef = roles.find((declared) => declared.id.trim() === role.trim());
2054
- if (roleDef === void 0) warn(`llm-fallbacks: unknown role "${role}" — falling back to rootChain`);
2055
- const roleChain = roleDef?.chain ?? [];
2056
- const chain = roleChain.length > 0 ? roleChain : roleDef?.fallback === "none" ? [] : rootChain;
2057
- const inherit = rootChain.length > 0 && (roleDef === void 0 || roleDef.fallback !== "none");
2058
- return {
2059
- chainRole: roleChain.length > 0,
2060
- chain,
2061
- inherit
2062
- };
2063
- }
2064
2307
  function formatSwitch(entry, t) {
2065
2308
  const from = `${entry.from.provider}/${entry.from.model}`;
2066
2309
  const to = `${entry.to.provider}/${entry.to.model}`;
@@ -2076,7 +2319,7 @@ function formatConfigList(items) {
2076
2319
  return [...items.slice(0, 5), "…"].join(", ");
2077
2320
  }
2078
2321
  function formatConfigRoles(roles, t) {
2079
- 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));
2080
2323
  const list = items.length === 0 ? "" : `${items.join(", ")}${roles.length > 5 ? ", …" : ""}`;
2081
2324
  return `${roles.length}${list.length === 0 ? "" : ` — ${list}`}`;
2082
2325
  }
@@ -2131,27 +2374,18 @@ function formatConfigTimeSlots(slots, t) {
2131
2374
  const list = items.length === 0 ? "" : `${items.join(", ")}${slots.length > 5 ? ", …" : ""}`;
2132
2375
  return `${slots.length}${list.length === 0 ? "" : ` — ${list}`}`;
2133
2376
  }
2134
- function formatConfigRules(rules, t) {
2135
- const items = rules.slice(0, 5).map((rule) => `${rule.provider === "" ? "*" : rule.provider}/${rule.model === "" ? "*" : rule.model} → ${rule.role}`);
2136
- const list = items.length === 0 ? "" : `${items.join(", ")}${rules.length > 5 ? ", …" : ""}`;
2137
- return `${rules.length}${list.length === 0 ? "" : ` — ${list}`}`;
2138
- }
2139
2377
  function fallbacksConfigText(summary, locale = "zh") {
2140
2378
  const t = FALLBACKS_COMMAND_LOCALES[locale];
2141
2379
  return [
2142
2380
  `${t.configTitle}: ${summary.active ? t.configEnabled : t.configDisabled}`,
2143
2381
  `${t.configTriggerCodes}: ${summary.triggerCodes.length === 0 ? t.configEmpty : formatConfigList(summary.triggerCodes)}`,
2144
- `${t.configRootChain}: ${summary.rootChain.length === 0 ? t.configEmpty : formatConfigList(summary.rootChain)}`,
2145
- `${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))),
2146
2383
  `${t.configTz}: ${summary.tz}`,
2147
2384
  `${t.configRoles}: ${formatConfigRoles(summary.roles, t)}`,
2148
- `${t.configRules}: ${summary.rules.length === 0 ? t.configEmpty : formatConfigRules(summary.rules, t)}`,
2149
2385
  `${t.configCooldown}: ${summary.cooldownMs} ms`,
2150
2386
  `${t.configRevert}: ${summary.revertPolicy}`,
2151
2387
  `${t.configMaxSwitches}: ${summary.maxSwitchesPerStep}`,
2152
- `${t.configAlwaysCap}: ${summary.alwaysModeRetryCap}`,
2153
2388
  `${t.configPresets}: ${summary.presets}`,
2154
- `${t.configRoleAutoMatch}: ${summary.roleAutoMatch ? t.configEnabled : t.configDisabled}`,
2155
2389
  "",
2156
2390
  t.configEdit,
2157
2391
  t.configEditHint
@@ -2161,13 +2395,13 @@ function fallbacksCommandText(snapshot, locale = "zh") {
2161
2395
  const t = FALLBACKS_COMMAND_LOCALES[locale];
2162
2396
  const lines = [t.title];
2163
2397
  lines.push(`${t.origin}: ${snapshot.origin}`);
2164
- lines.push(`${t.role}: ${snapshot.role}`);
2165
- if (snapshot.chain.length === 0) lines.push(`${t.chain}: ${t.chainNone}`);
2166
- else {
2167
- const suffix = snapshot.inherit ? t.inheritRoot : "";
2168
- 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}`);
2169
2404
  }
2170
- lines.push(`${t.slot}: ${snapshot.slot.label}`);
2171
2405
  if (snapshot.switches.length === 0) lines.push(`${t.switches}: ${t.switchesNone}`);
2172
2406
  else {
2173
2407
  lines.push(`${t.switches} (${snapshot.switches.length}):`);
@@ -2262,9 +2496,7 @@ var FallbacksSeedManager = class {
2262
2496
  });
2263
2497
  outcome.applied.push(seed.id);
2264
2498
  }
2265
- const config = io.read();
2266
- const currentList = roleRows(config);
2267
- const currentRules = roleRules(config);
2499
+ const currentList = roleRows(io.read());
2268
2500
  const candidate = new Map(this.registry);
2269
2501
  if (entries.size === 0) candidate.delete(source);
2270
2502
  else candidate.set(source, {
@@ -2281,10 +2513,7 @@ var FallbacksSeedManager = class {
2281
2513
  }
2282
2514
  const newList = materialize(currentList, incoming, prior, outcome.conflicts);
2283
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)`);
2284
- const computed = {
2285
- list: newList,
2286
- rules: currentRules
2287
- };
2516
+ const computed = { list: newList };
2288
2517
  if (!deepEqual(newList, currentList)) await io.writeRoles(computed);
2289
2518
  if (entries.size === 0) this.registry.delete(source);
2290
2519
  else this.registry.set(source, {
@@ -2328,8 +2557,7 @@ var FallbacksSeedManager = class {
2328
2557
  source: seeded ? declared.source : "user"
2329
2558
  };
2330
2559
  if (seeded) effective.seedPersona = declared.persona;
2331
- if (row.chain !== void 0) effective.chain = row.chain;
2332
- if (row.fallback !== void 0) effective.fallback = row.fallback;
2560
+ if (row.chain !== void 0 && row.chain !== "") effective.chain = row.chain;
2333
2561
  return effective;
2334
2562
  }) };
2335
2563
  }
@@ -2353,9 +2581,7 @@ var FallbacksSeedManager = class {
2353
2581
  reverted: false,
2354
2582
  reason: "not-seeded"
2355
2583
  };
2356
- const config = io.read();
2357
- const rows = roleRows(config);
2358
- const rules = roleRules(config);
2584
+ const rows = roleRows(io.read());
2359
2585
  const index = rows.findIndex((row) => row.id.trim() === seedId);
2360
2586
  if (index === -1) return {
2361
2587
  reverted: false,
@@ -2369,10 +2595,7 @@ var FallbacksSeedManager = class {
2369
2595
  ...row,
2370
2596
  persona: declared.persona
2371
2597
  } : row);
2372
- await io.writeRoles({
2373
- list: nextList,
2374
- rules
2375
- });
2598
+ await io.writeRoles({ list: nextList });
2376
2599
  return {
2377
2600
  reverted: true,
2378
2601
  persona: declared.persona
@@ -2441,10 +2664,6 @@ function roleRows(config) {
2441
2664
  const list = config.roles?.list;
2442
2665
  return Array.isArray(list) ? list : [];
2443
2666
  }
2444
- function roleRules(config) {
2445
- const rules = config.roles?.rules;
2446
- return Array.isArray(rules) ? rules : [];
2447
- }
2448
2667
  function deepEqual(a, b) {
2449
2668
  if (a === b) return true;
2450
2669
  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
@@ -2508,7 +2727,7 @@ function fallbacksChildren(canonicalPath) {
2508
2727
  if (canonicalPath.length === 2 && canonicalPath[1] === "config") return [REVERT_SEED_NODE];
2509
2728
  return [];
2510
2729
  }
2511
- const FALLBACKS_PROVIDER$1 = {
2730
+ const FALLBACKS_PROVIDER = {
2512
2731
  root: FALLBACKS_TUI_ROOT,
2513
2732
  descriptions: {
2514
2733
  zh: FALLBACKS_COMMAND_LOCALES.zh.description,
@@ -2522,7 +2741,7 @@ function installTuiClient(ctx, opts) {
2522
2741
  const trees = tctx.tuiCommandTrees;
2523
2742
  if (trees === void 0) return;
2524
2743
  try {
2525
- return trees.register(FALLBACKS_PROVIDER$1);
2744
+ return trees.register(FALLBACKS_PROVIDER);
2526
2745
  } catch (error) {
2527
2746
  if (!(error instanceof Error) || !error.message.includes("already registered")) throw error;
2528
2747
  tctx.logger("llm-fallbacks").debug("llm-fallbacks: tui command tree already registered — no provider on this fiber");
@@ -2593,8 +2812,8 @@ function buildFallbacksTuiSection() {
2593
2812
  ns: FALLBACKS_TUI_SECTION_NS,
2594
2813
  title: "fallbacks",
2595
2814
  descriptions: {
2596
- zh: "回退设置:降级链、分时切换、角色与高级选项(与 Web 设置卡片完全一致)。",
2597
- en: "Fallback settings: degradation chains, time slots, roles, and advanced options (full parity with the web settings card)."
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."
2598
2817
  },
2599
2818
  groups: [
2600
2819
  {
@@ -2631,16 +2850,6 @@ function buildFallbacksTuiSection() {
2631
2850
  }
2632
2851
  ],
2633
2852
  fields: [
2634
- {
2635
- path: ["roleAutoMatch"],
2636
- label: "LLM role auto-match",
2637
- descriptions: {
2638
- zh: "LLM 角色自动匹配",
2639
- en: "LLM role auto-match"
2640
- },
2641
- group: "general",
2642
- kind: "boolean"
2643
- },
2644
2853
  {
2645
2854
  path: ["presets"],
2646
2855
  label: "Preset roles",
@@ -2700,63 +2909,34 @@ function buildFallbacksTuiSection() {
2700
2909
  parse: tzParse
2701
2910
  },
2702
2911
  {
2703
- path: ["rootChain"],
2704
- label: "Root chain (JSON)",
2912
+ path: ["chains"],
2913
+ label: "Chains (JSON)",
2705
2914
  descriptions: {
2706
- zh: "根降级链(JSON)",
2707
- en: "Root chain (JSON)"
2915
+ zh: "虚拟链(JSON)",
2916
+ en: "Chains (JSON)"
2708
2917
  },
2709
- 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.",
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).",
2710
2919
  hintDescriptions: {
2711
- zh: "模型选择器 JSON 数组;末项必须是官方模型(deepseek-official/deepseek-flash 或 deepseek-official/deepseek-v4-pro)。Pro 已在目录中提供——设置卡中不再禁用。",
2712
- 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."
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)."
2713
2922
  },
2714
2923
  group: "chain",
2715
2924
  kind: "text",
2716
2925
  format: jsonFieldFormat,
2717
- parse: jsonFieldParse((parsed) => ({ rootChain: parsed }))
2718
- },
2719
- {
2720
- path: ["timeSlots"],
2721
- label: "Time slots (JSON)",
2722
- descriptions: {
2723
- zh: "分时切换(JSON)",
2724
- en: "Time slots (JSON)"
2725
- },
2726
- hint: "JSON array of slot rows (kind: preset|custom).",
2727
- hintDescriptions: {
2728
- zh: "分时行 JSON 数组(kind: preset|custom)。",
2729
- en: "JSON array of slot rows (kind: preset|custom)."
2730
- },
2731
- group: "chain",
2732
- kind: "text",
2733
- format: jsonFieldFormat,
2734
- parse: jsonFieldParse((parsed) => ({ timeSlots: parsed }))
2926
+ parse: jsonFieldParse((parsed) => ({ chains: parsed }))
2735
2927
  },
2736
2928
  {
2737
2929
  path: ["roles", "list"],
2738
2930
  label: "Roles (JSON)",
2739
2931
  descriptions: {
2740
- zh: "角色列表(JSON)",
2741
- en: "Roles (JSON)"
2932
+ zh: "角色列表(JSON,chain 为指定降级链指针)",
2933
+ en: "Roles (JSON; chain is the specified-chain pointer)"
2742
2934
  },
2743
2935
  group: "roles",
2744
2936
  kind: "text",
2745
2937
  format: jsonFieldFormat,
2746
2938
  parse: jsonFieldParse((parsed) => ({ roles: { list: parsed } }))
2747
2939
  },
2748
- {
2749
- path: ["roles", "rules"],
2750
- label: "Role rules (JSON)",
2751
- descriptions: {
2752
- zh: "角色规则(JSON)",
2753
- en: "Role rules (JSON)"
2754
- },
2755
- group: "roles",
2756
- kind: "text",
2757
- format: jsonFieldFormat,
2758
- parse: jsonFieldParse((parsed) => ({ roles: { rules: parsed } }))
2759
- },
2760
2940
  {
2761
2941
  path: ["cooldownMs"],
2762
2942
  label: "Cooldown (ms)",
@@ -2769,20 +2949,10 @@ function buildFallbacksTuiSection() {
2769
2949
  },
2770
2950
  {
2771
2951
  path: ["maxSwitchesPerStep"],
2772
- label: "Max switches per step",
2773
- descriptions: {
2774
- zh: "单步最大切换次数",
2775
- en: "Max switches per step"
2776
- },
2777
- group: "advanced",
2778
- kind: "number"
2779
- },
2780
- {
2781
- path: ["alwaysModeRetryCap"],
2782
- label: "Always-mode retry cap",
2952
+ label: "Max switches per request walk",
2783
2953
  descriptions: {
2784
- zh: "Always 模式重试上限",
2785
- en: "Always-mode retry cap"
2954
+ zh: "单次请求行走最大切换",
2955
+ en: "Max switches per request walk"
2786
2956
  },
2787
2957
  group: "advanced",
2788
2958
  kind: "number"
@@ -2830,343 +3000,14 @@ function installTuiSettingsSection(ctx, opts) {
2830
3000
  });
2831
3001
  }
2832
3002
  //#endregion
2833
- //#region .build/host/virtual-adapter.js
2834
- const FALLBACKS_PROVIDER = "FallbacksChain";
2835
- const FALLBACKS_CHAIN_MODEL = "Auto";
2836
- const EMPTY_EFFECTIVE_CHAIN_CODE = "EMPTY_EFFECTIVE_CHAIN";
2837
- const UNDISPATCHABLE_HEAD_CODE = "UNDISPATCHABLE_EFFECTIVE_HEAD";
2838
- const LLM_UNAVAILABLE_CODE = "LLM_UNAVAILABLE";
2839
- function firstDispatchableExactHead(chain) {
2840
- for (const entry of chain) {
2841
- let selector;
2842
- try {
2843
- selector = parseSelector(entry);
2844
- } catch {
2845
- continue;
2846
- }
2847
- if (selector.model === void 0) continue;
2848
- if (selector.provider === "FallbacksChain") continue;
2849
- return {
2850
- provider: selector.provider,
2851
- model: selector.model
2852
- };
2853
- }
2854
- }
2855
- function effectiveHeadOf(config, now) {
2856
- if (!isAllDayConforming(config.rootChain)) return void 0;
2857
- return firstDispatchableExactHead(resolveEffectiveChain(config, now, config.tz ?? "Asia/Shanghai"));
2858
- }
2859
- function pickerDisplayName(config, now = new Date(), modelDisplayName) {
2860
- const head = effectiveHeadOf(config, now);
2861
- if (head === void 0) return FALLBACKS_CHAIN_MODEL;
2862
- const slot = resolveSlotState(config, now, config.tz ?? "Asia/Shanghai");
2863
- const model = modelDisplayName !== void 0 && modelDisplayName !== "" ? modelDisplayName : head.model;
2864
- return `${FALLBACKS_CHAIN_MODEL}: ${model}[${slot.label}]`;
2865
- }
2866
- async function resolveHeadDisplayName(llm, head) {
2867
- if (llm === void 0) return head.model;
2868
- try {
2869
- const row = (await llm.listModels(head.provider)).find((model) => model.id === head.model);
2870
- if (row !== void 0 && row.name !== "") return row.name;
2871
- } catch {}
2872
- try {
2873
- const info = await llm.resolveModelInfo(head.provider, head.model);
2874
- if (info.name !== "") return info.name;
2875
- } catch {}
2876
- return head.model;
2877
- }
2878
- function envelopeProvenance(replayState) {
2879
- if (typeof replayState !== "object" || replayState === null) return void 0;
2880
- const response = replayState.response;
2881
- if (typeof response !== "object" || response === null) return void 0;
2882
- const { provider, model } = response;
2883
- if (typeof provider !== "string" || provider === "" || provider === "FallbacksChain") return void 0;
2884
- if (typeof model !== "string" || model === "") return void 0;
2885
- return {
2886
- provider,
2887
- model
2888
- };
2889
- }
2890
- function restoreEnvelopeProvenance(messages) {
2891
- let changed = false;
2892
- const restored = messages.map((message) => {
2893
- const source = message.source;
2894
- if (message.role !== "assistant" || source?.kind !== "model") return message;
2895
- if (source.provider !== "FallbacksChain" || source.model !== "Auto") return message;
2896
- const provenance = envelopeProvenance(source.replayState);
2897
- if (provenance === void 0) return message;
2898
- changed = true;
2899
- return {
2900
- ...message,
2901
- source: {
2902
- ...source,
2903
- ...provenance
2904
- }
2905
- };
2906
- });
2907
- return changed ? restored : messages;
2908
- }
2909
- var FallbacksChainAdapter = class extends LlmAdapter {
2910
- readConfig;
2911
- getLlm;
2912
- constructor(readConfig, getLlm) {
2913
- super();
2914
- this.readConfig = readConfig;
2915
- this.getLlm = getLlm;
2916
- }
2917
- providerInfo(provider) {
2918
- return {
2919
- id: provider,
2920
- name: FALLBACKS_PROVIDER
2921
- };
2922
- }
2923
- async listModels(provider) {
2924
- const config = this.readConfig();
2925
- const now = new Date();
2926
- const head = effectiveHeadOf(config, now);
2927
- const display = head === void 0 ? void 0 : await resolveHeadDisplayName(this.getLlm(), head);
2928
- return [{
2929
- provider,
2930
- id: FALLBACKS_CHAIN_MODEL,
2931
- name: pickerDisplayName(config, now, display)
2932
- }];
2933
- }
2934
- async resolveModel(provider, model, signal) {
2935
- const head = effectiveHeadOf(this.readConfig(), new Date());
2936
- const llm = this.getLlm();
2937
- if (head !== void 0 && llm !== void 0) try {
2938
- const info = await llm.resolveModelInfo(head.provider, head.model, signal);
2939
- return {
2940
- provider,
2941
- id: model,
2942
- name: info.name,
2943
- ...info.description === void 0 ? {} : { description: info.description },
2944
- ...info.inputModalities === void 0 ? {} : { inputModalities: info.inputModalities },
2945
- ...info.context === void 0 ? {} : { context: info.context },
2946
- ...info.defaultMaxTokens === void 0 ? {} : { defaultMaxTokens: info.defaultMaxTokens },
2947
- ...info.reasoning === void 0 ? {} : { reasoning: info.reasoning }
2948
- };
2949
- } catch {}
2950
- return {
2951
- provider,
2952
- id: model,
2953
- name: model
2954
- };
2955
- }
2956
- providerRetryPolicy(_provider) {
2957
- try {
2958
- const head = effectiveHeadOf(this.readConfig(), new Date());
2959
- if (head === void 0 || head.provider === "FallbacksChain") return void 0;
2960
- return this.getLlm()?.providerRetryPolicy(head.provider);
2961
- } catch {
2962
- return;
2963
- }
2964
- }
2965
- imageRequestPricing(_provider, _model) {
2966
- try {
2967
- const head = effectiveHeadOf(this.readConfig(), new Date());
2968
- if (head === void 0 || head.provider === "FallbacksChain") return void 0;
2969
- return this.getLlm()?.imageRequestPricing(head.provider, head.model);
2970
- } catch {
2971
- return;
2972
- }
2973
- }
2974
- stream(options) {
2975
- const config = this.readConfig();
2976
- const now = new Date();
2977
- if (resolveEffectiveChain(config, now, config.tz ?? "Asia/Shanghai").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);
2978
- const head = effectiveHeadOf(config, now);
2979
- if (head === void 0) throw new LlmError("llm-fallbacks: the effective chain head cannot be dispatched (non-conforming all-day, wildcard, malformed, or self-route) — refusing to delegate", UNDISPATCHABLE_HEAD_CODE);
2980
- const llm = this.getLlm();
2981
- if (llm === void 0) throw new LlmError("llm-fallbacks: the llm runtime is unavailable — cannot delegate the FallbacksChain call", LLM_UNAVAILABLE_CODE);
2982
- return llm.stream({
2983
- ...options,
2984
- provider: head.provider,
2985
- model: head.model,
2986
- messages: restoreEnvelopeProvenance(options.messages)
2987
- });
2988
- }
2989
- };
2990
- function installFallbacksAdapter(ctx, readConfig) {
2991
- let llm;
2992
- let disposeAdapter;
2993
- let registered = false;
2994
- const logger = ctx.logger("llm-fallbacks");
2995
- const adapter = new FallbacksChainAdapter(readConfig, () => llm);
2996
- const reconcile = () => {
2997
- const shouldRegister = isFallbackActive(readConfig());
2998
- if (shouldRegister && !registered) {
2999
- if (llm === void 0) return;
3000
- try {
3001
- disposeAdapter = llm.registerAdapter([FALLBACKS_PROVIDER], adapter);
3002
- registered = true;
3003
- logger.info("llm-fallbacks: virtual adapter registered — FallbacksChain/Auto is selectable in the model picker");
3004
- } catch (error) {
3005
- if (!(error instanceof Error) || !("code" in error) || error.code !== "DUPLICATE_ADAPTER") throw error;
3006
- logger.debug("llm-fallbacks: virtual adapter already registered — no route on this fiber (multi-fiber dedupe)");
3007
- }
3008
- } else if (!shouldRegister && registered) {
3009
- disposeAdapter?.();
3010
- disposeAdapter = void 0;
3011
- registered = false;
3012
- logger.info("llm-fallbacks: virtual adapter unregistered — FallbacksChain/Auto hidden from the model picker");
3013
- }
3014
- };
3015
- ctx.inject(["llm"], (llmCtx) => {
3016
- llm = llmCtx.llm;
3017
- reconcile();
3018
- return () => {
3019
- disposeAdapter?.();
3020
- disposeAdapter = void 0;
3021
- registered = false;
3022
- llm = void 0;
3023
- };
3024
- });
3025
- return reconcile;
3026
- }
3027
- //#endregion
3028
3003
  //#region .build/host/index.js
3029
3004
  const name = "llm-fallbacks";
3030
3005
  const provide = ["llm-fallbacks"];
3031
3006
  const { version } = createRequire(import.meta.url)("../package.json");
3032
- const CONTEXT_WINDOW_EXCEEDED_CODE = "CONTEXT_WINDOW_EXCEEDED";
3033
- const REQUEST_SCOPED_CODES = new Set([CONTEXT_WINDOW_EXCEEDED_CODE]);
3034
3007
  const stateStores = new WeakMap();
3035
3008
  function stateStore(ctx) {
3036
3009
  return stateStores.get(ctx);
3037
3010
  }
3038
- const blockedAttemptStores = new WeakMap();
3039
- function blockedAttempts(ctx) {
3040
- return blockedAttemptStores.get(ctx);
3041
- }
3042
- const chainHeadStores = new WeakMap();
3043
- function chainHeads(ctx) {
3044
- return chainHeadStores.get(ctx);
3045
- }
3046
- function latestByAt(map) {
3047
- let latest;
3048
- for (const value of map.values()) if (latest === void 0 || value.at >= latest.at) latest = value;
3049
- return latest;
3050
- }
3051
- async function makeModelExists(ctx, providers) {
3052
- const llm = ctx.get("llm");
3053
- if (llm === void 0 || typeof llm.listModels !== "function") return () => true;
3054
- const catalog = new Map();
3055
- await Promise.all(providers.map(async (provider) => {
3056
- try {
3057
- const models = await llm.listModels(provider);
3058
- catalog.set(provider, new Set(models.map((model) => model.id)));
3059
- } catch {
3060
- catalog.set(provider, new Set());
3061
- }
3062
- }));
3063
- return (provider, model) => catalog.get(provider)?.has(model) ?? false;
3064
- }
3065
- async function makeContextWindowOf(ctx, routes) {
3066
- const llm = ctx.get("llm");
3067
- if (llm === void 0) return () => void 0;
3068
- const windows = new Map();
3069
- if (typeof llm.listModels === "function") await Promise.all([...new Set(routes.map((route) => route.provider))].map(async (provider) => {
3070
- try {
3071
- for (const model of await llm.listModels(provider)) if (typeof model.contextWindow === "number") windows.set(selectorKey(provider, model.id), model.contextWindow);
3072
- } catch {}
3073
- }));
3074
- const resolveModelInfo = llm.resolveModelInfo?.bind(llm);
3075
- if (resolveModelInfo !== void 0) await Promise.all(routes.map(async (route) => {
3076
- const key = selectorKey(route.provider, route.model);
3077
- if (windows.has(key)) return;
3078
- try {
3079
- const window = (await resolveModelInfo(route.provider, route.model))?.context?.contextWindow;
3080
- if (typeof window === "number") windows.set(key, window);
3081
- } catch {}
3082
- }));
3083
- return (provider, model) => windows.get(selectorKey(provider, model));
3084
- }
3085
- function countRetryEvents(session, turn, step, provider) {
3086
- let count = 0;
3087
- const events = session.snapshotEvents();
3088
- for (let index = events.length - 1; index >= 0; index -= 1) {
3089
- const event = events[index];
3090
- const data = event.data;
3091
- if (typeof data.turn === "number" && typeof data.step === "number" && (data.turn < turn || data.turn === turn && data.step < step)) break;
3092
- if (event.type !== "llm/retry") continue;
3093
- if (data.turn === turn && data.step === step && event.data.provider === provider && event.data.mode === "always") count += 1;
3094
- }
3095
- return count;
3096
- }
3097
- function anchorServedRoute(config, route, now) {
3098
- if (route.provider !== "FallbacksChain" || route.model !== "Auto") return route;
3099
- const head = effectiveHeadOf(config, now);
3100
- return head === void 0 ? route : {
3101
- provider: head.provider,
3102
- model: head.model
3103
- };
3104
- }
3105
- function servedRouteFor(agent, config) {
3106
- const provider = agent.options?.provider;
3107
- const model = agent.options?.model;
3108
- if (provider === void 0 || model === void 0) return void 0;
3109
- const anchored = anchorServedRoute(config, {
3110
- provider,
3111
- model
3112
- }, new Date());
3113
- return anchored.provider === provider && anchored.model === model ? void 0 : anchored;
3114
- }
3115
- function currentModel(agent, provider, config, now) {
3116
- return anchorServedRoute(config, {
3117
- provider,
3118
- model: agent.session.requestHeader()?.config.model ?? agent.options.model ?? ""
3119
- }, now);
3120
- }
3121
- function slotWinnerKey(winner) {
3122
- if (winner === "all-day") return "all-day";
3123
- return winner.kind === "preset" ? `preset:${winner.preset}` : `custom:${winner.start}-${winner.end}`;
3124
- }
3125
- function overrideConfig(seed, to, explicitEffort) {
3126
- return overrideConfigWithRouteRule(seed, to, explicitEffort);
3127
- }
3128
- function readSubagentSettings(ctx) {
3129
- const service = ctx.get("subagentModelSelection");
3130
- return service === void 0 ? void 0 : service.current();
3131
- }
3132
- function readGuardedPolicySettings(ctx, agentId, lastKnown, warn) {
3133
- try {
3134
- const service = ctx.get("subagentModelSelection");
3135
- if (service === void 0) return {
3136
- ok: true,
3137
- settings: lastKnown.get(agentId)
3138
- };
3139
- const settings = service.current();
3140
- lastKnown.set(agentId, settings);
3141
- return {
3142
- ok: true,
3143
- settings
3144
- };
3145
- } catch (error) {
3146
- warn("llm-fallbacks: agent \"%s\" subagent model-selection settings read failed — %s", agentId, error?.message ?? String(error));
3147
- const known = lastKnown.get(agentId);
3148
- return known === void 0 ? { ok: false } : {
3149
- ok: true,
3150
- settings: known
3151
- };
3152
- }
3153
- }
3154
- function authorizedRouteView(ctx, agent) {
3155
- const header = agent.session?.header;
3156
- const parentId = header?.origin === "subagent" ? header.parentSession : void 0;
3157
- const parent = parentId === void 0 ? void 0 : ctx.get("agents")?.get(parentId);
3158
- const parentHeader = parent?.session.requestHeader()?.config;
3159
- const parentOptions = parent?.options;
3160
- return {
3161
- requestHeader: agent.session?.requestHeader()?.config,
3162
- events: agent.session?.snapshotEvents(),
3163
- options: agent.options,
3164
- inherited: parentHeader ?? (typeof parentOptions?.provider === "string" && typeof parentOptions?.model === "string" ? {
3165
- provider: parentOptions.provider,
3166
- model: parentOptions.model
3167
- } : void 0)
3168
- };
3169
- }
3170
3011
  function isLiveConfigRef(value) {
3171
3012
  return typeof value === "object" && value !== null && typeof value.get === "function";
3172
3013
  }
@@ -3194,12 +3035,11 @@ function apply(ctx, config = defaultFallbacksConfig) {
3194
3035
  throw new Error(seedsSettingsUnavailable);
3195
3036
  };
3196
3037
  ctx.inject(["settings"], (sctx) => {
3197
- writeRoles = (roles) => sctx.settings.update(FALLBACKS_PROFILE_ENTRY, { roles });
3038
+ writeRoles = (roles) => writeFallbacksSection(sctx.settings, { roles });
3198
3039
  try {
3199
3040
  sctx.provide("llm-fallbacks", {
3200
3041
  name: "llm-fallbacks",
3201
3042
  version,
3202
- resolveRole,
3203
3043
  resolveChain,
3204
3044
  validateFallbacksConfig,
3205
3045
  detectLegacyKeys,
@@ -3226,17 +3066,16 @@ function apply(ctx, config = defaultFallbacksConfig) {
3226
3066
  };
3227
3067
  let serviceOwned = ctx.get("llm-fallbacks") === void 0;
3228
3068
  let settingsComposed;
3229
- let source = () => {
3069
+ const rawSource = () => {
3230
3070
  if (isLiveConfigRef(config)) return config.get();
3231
3071
  if (settingsComposed !== void 0) return settingsComposed;
3232
3072
  return entry;
3233
3073
  };
3234
- const reconcileFallbacksAdapter = installFallbacksAdapter(ctx, () => source());
3235
- validateFallbacksConfig(entry, logger);
3236
- const legacyKeys = detectLegacyKeys(source());
3237
- 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);
3238
3078
  let roleIds = new Map(entry.roles.list.map((role) => [role.id.trim(), role.id]));
3239
- let hasChains = entry.rootChain.length > 0 || entry.roles.list.some((role) => (role.chain?.length ?? 0) > 0);
3240
3079
  ctx.inject(["settings"], (sctx) => {
3241
3080
  const settings = sctx.settings;
3242
3081
  const refreshComposed = () => {
@@ -3249,7 +3088,6 @@ function apply(ctx, config = defaultFallbacksConfig) {
3249
3088
  if (!isLiveConfigRef(config)) refreshComposed();
3250
3089
  const current = source();
3251
3090
  roleIds = new Map(current.roles.list.map((role) => [role.id.trim(), role.id]));
3252
- hasChains = current.rootChain.length > 0 || current.roles.list.some((role) => (role.chain?.length ?? 0) > 0);
3253
3091
  reconcileFallbacksAdapter();
3254
3092
  slotWinners.clear();
3255
3093
  });
@@ -3258,14 +3096,14 @@ function apply(ctx, config = defaultFallbacksConfig) {
3258
3096
  settingsComposed = void 0;
3259
3097
  };
3260
3098
  });
3261
- const bridge = { source: () => source() };
3262
- const chainHeadMap = new Map();
3263
- chainHeadStores.set(ctx, chainHeadMap);
3264
- const blockedAttemptMap = new Map();
3265
- blockedAttemptStores.set(ctx, blockedAttemptMap);
3099
+ const bridge = {
3100
+ source: () => source(),
3101
+ rawSource: () => rawSource()
3102
+ };
3266
3103
  const subagentSeam = installSubagentSeam(ctx, {
3267
3104
  roleIds: () => roleIds,
3268
3105
  roles: () => source().roles.list,
3106
+ source: () => source(),
3269
3107
  debug: (message) => logger.debug(message)
3270
3108
  });
3271
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)");
@@ -3275,20 +3113,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
3275
3113
  debug: (message) => logger.debug(message)
3276
3114
  });
3277
3115
  try {
3278
- new FallbacksConfigGateway(ctx, bridge, seeds, () => {
3279
- try {
3280
- return {
3281
- policy: effectivePolicy({
3282
- ok: false,
3283
- present: false
3284
- }, readSubagentSettings(ctx)),
3285
- head: latestByAt(chainHeadMap),
3286
- blockedAttempt: latestByAt(blockedAttemptMap)
3287
- };
3288
- } catch {
3289
- return { policy: { state: "unprovable" } };
3290
- }
3291
- });
3116
+ new FallbacksConfigGateway(ctx, bridge, seeds);
3292
3117
  } catch (error) {
3293
3118
  if (!(error instanceof Error) || !error.message.includes("has been registered")) throw error;
3294
3119
  ctx.logger("llm-fallbacks").debug("fallbacks gateway already registered — no gateway on this fiber (multi-fiber dedupe)");
@@ -3304,283 +3129,48 @@ function apply(ctx, config = defaultFallbacksConfig) {
3304
3129
  });
3305
3130
  const states = new FallbackStateStore();
3306
3131
  stateStores.set(ctx, states);
3307
- const dispatchInjected = new Set();
3308
- const dispatchRoles = new Map();
3309
- const rememberedRole = (agentId) => {
3310
- const role = dispatchRoles.get(agentId);
3311
- return role !== void 0 && roleIds.has(role.trim()) ? role : void 0;
3312
- };
3313
3132
  const slotWinners = new Map();
3314
- const lastKnownPolicySettings = new Map();
3315
- async function decide(agent, turn, step, current, reason, state, failureCode) {
3316
- const config = source();
3317
- if (state !== void 0) {
3318
- states.syncStep(state, turn, step);
3319
- if (state.stepFailures.switchCount >= config.maxSwitchesPerStep) return null;
3320
- }
3321
- const role = rememberedRole(agent.id) ?? resolveRole(agent, config.roles.rules, roleIds, logger.warn, servedRouteFor(agent, config));
3322
- const rootTail = agent.session?.header?.origin === "subagent" ? config.rootChain : resolveEffectiveChain(config, new Date(), config.tz ?? "Asia/Shanghai");
3323
- const { all, wildcard } = resolveChainViews(config.roles.list, rootTail, role, current.provider, current.model, logger.warn);
3324
- if (all.length === 0) return null;
3325
- const modelExists = hasWildcardEntry(config.roles.list, rootTail, role) ? await makeModelExists(ctx, [...new Set(all.map((candidate) => candidate.provider))]) : void 0;
3326
- const cooldown = { isSuppressed: (key) => state !== void 0 && states.isSuppressed(state, key, Date.now(), source().recovery ?? "timer") };
3327
- const failed = { has: (key) => state !== void 0 && state.stepFailures.failed.has(key) };
3328
- const contextWindowOf = failureCode === CONTEXT_WINDOW_EXCEEDED_CODE ? await makeContextWindowOf(ctx, [current, ...all.flatMap((candidate) => candidate.model === void 0 ? [] : [{
3329
- provider: candidate.provider,
3330
- model: candidate.model
3331
- }])]) : void 0;
3332
- const minContextWindow = contextWindowOf?.(current.provider, current.model);
3333
- const surviving = selectCandidates(all, wildcard, createCandidateFilter({
3334
- current,
3335
- cooldown,
3336
- failed,
3337
- minContextWindow,
3338
- contextWindowOf
3339
- }), modelExists);
3340
- const first = surviving[0];
3341
- let target;
3342
- if (first !== void 0 && first.model !== void 0) target = {
3343
- provider: first.provider,
3344
- model: first.model
3345
- };
3346
- if (agent.session?.header?.origin === "subagent" && target !== void 0) {
3347
- const settingsRead = readGuardedPolicySettings(ctx, agent.id, lastKnownPolicySettings, logger.warn);
3348
- const policy = settingsRead.ok ? effectivePolicy(readSessionPolicyEvent(agent.session.snapshotEvents()), settingsRead.settings) : { state: "unprovable" };
3349
- if (policy.state === "unprovable") {
3350
- logger.warn("llm-fallbacks: agent \"%s\" subagent model-selection policy is on but unreadable — skipping fallback switch", agent.id);
3351
- return null;
3352
- }
3353
- if (policy.state === "enabled") {
3354
- const allowed = firstAllowedCandidate(resolvedRoutes(surviving), policy.allowedModels);
3355
- if (allowed === void 0) {
3356
- logger.warn("llm-fallbacks: agent \"%s\" fallback switch %s/%s blocked: resolved candidates are all outside the subagent allowlist — no switch", agent.id, target.provider, target.model);
3357
- blockedAttemptMap.set(agent.id, {
3358
- at: Date.now(),
3359
- route: {
3360
- provider: target.provider,
3361
- model: target.model
3362
- },
3363
- reason
3364
- });
3365
- return null;
3366
- }
3367
- target = {
3368
- provider: allowed.provider,
3369
- model: allowed.model
3370
- };
3371
- }
3372
- }
3373
- if (target === void 0) return null;
3374
- if ((source().recovery ?? "timer") === "half-open" && state !== void 0 && state.recovery.tryMarkProbeLogged(selectorKey(target.provider, target.model))) logger.info("llm-fallbacks: agent \"%s\" half-open probe %s/%s (role=%s, reason=%s)", agent.id, target.provider, target.model, role, reason);
3375
- logger.info("llm-fallbacks: agent \"%s\" fallback switch (降级切换) %s/%s -> %s/%s (role=%s, reason=%s, candidates=%o)", agent.id, current.provider, current.model, target.provider, target.model, role, reason, annotateCandidates(all, surviving, {
3376
- current,
3377
- cooldown,
3378
- failed,
3379
- minContextWindow,
3380
- contextWindowOf
3381
- }).map(({ candidate, skip }) => skip === void 0 ? `${candidate.provider}/${candidate.model}` : `${candidate.provider}/${candidate.model} (skipped: ${skip})`));
3382
- return {
3383
- from: {
3384
- provider: current.provider,
3385
- model: current.model
3386
- },
3387
- to: {
3388
- provider: target.provider,
3389
- model: target.model
3390
- },
3391
- role,
3392
- reason
3393
- };
3394
- }
3395
- function commit(state, pending, turn, step) {
3396
- const config = source();
3397
- const fromKey = selectorKey(pending.from.provider, pending.from.model);
3398
- states.syncStep(state, turn, step);
3399
- states.writePending(state, pending);
3400
- if ((pending.scope ?? "route") === "route") {
3401
- const until = config.revertPolicy === "never" ? Number.POSITIVE_INFINITY : config.recovery === "half-open" ? Date.now() + escalatedCooldownMs(config.cooldownMs, state.recovery.recordFailure(fromKey)) : Date.now() + config.cooldownMs;
3402
- states.suppress(state, fromKey, until);
3403
- }
3404
- states.recordFailure(state, fromKey);
3405
- states.recordSwitch(state);
3406
- }
3407
- function failHalfOpenProbe(agentId, current) {
3408
- const key = selectorKey(current.provider, current.model);
3409
- if ((source().recovery ?? "timer") !== "half-open") return;
3410
- if (source().revertPolicy === "never") return;
3411
- if (states.peek(agentId)?.recovery.isHalfOpen(key) !== true) return;
3412
- const state = states.get(agentId);
3413
- const n = state.recovery.recordFailure(key);
3414
- states.suppress(state, key, Date.now() + escalatedCooldownMs(source().cooldownMs, n));
3415
- }
3416
- ctx.on("agent/request-error", async ({ agent, turn, step, provider, failure }, next) => {
3417
- const config = source();
3418
- if (!isFallbackActive(config) || !config.triggerCodes.includes(failure.code)) return next();
3419
- const current = currentModel(agent, provider, config, new Date());
3420
- if (!current.model) return next();
3421
- try {
3422
- const pending = await decide(agent, turn, step, current, "trigger-code", states.peek(agent.id), failure.code);
3423
- if (pending === null) {
3424
- failHalfOpenProbe(agent.id, current);
3425
- return next();
3426
- }
3427
- const scope = REQUEST_SCOPED_CODES.has(failure.code) ? "request" : void 0;
3428
- commit(states.get(agent.id), scope === void 0 ? pending : {
3429
- ...pending,
3430
- scope
3431
- }, turn, step);
3432
- return { kind: "retry" };
3433
- } catch (error) {
3434
- logger.warn("llm-fallbacks: decision path failed, passing the original failure through: %s", error?.message ?? String(error));
3435
- return next();
3436
- }
3437
- });
3438
- ctx.on("agent/request", async ({ agent, turn, step }, next) => {
3439
- const seed = await next();
3440
- const state = states.peek(agent.id);
3441
- const applied = state === void 0 ? void 0 : states.applyPending(state, turn, step);
3442
- if (applied !== void 0) return overrideConfig(seed, applied.to);
3443
- const config = source();
3444
- if (isFallbackActive(config) && isAllDayConforming(config.rootChain) && (config.timeSlots?.length ?? 0) > 0 && agent.session?.header?.origin !== "subagent") {
3445
- const slot = resolveSlotState(config, new Date(), config.tz ?? "Asia/Shanghai");
3446
- const key = slotWinnerKey(slot.winner);
3447
- const previous = slotWinners.get(agent.id);
3448
- if (previous !== void 0 && previous.key !== key) logger.info("llm-fallbacks: agent \"%s\" time-slot switch (分时切换): %s -> %s", agent.id, previous.label, slot.label);
3449
- slotWinners.set(agent.id, {
3450
- key,
3451
- label: slot.label
3452
- });
3453
- }
3454
- if (isFallbackActive(config) && hasChains && agent.session?.header?.origin === "subagent" && !dispatchInjected.has(agent.id)) {
3455
- dispatchInjected.add(agent.id);
3456
- try {
3457
- const settingsRead = readGuardedPolicySettings(ctx, agent.id, lastKnownPolicySettings, logger.warn);
3458
- const policy = settingsRead.ok ? effectivePolicy(readSessionPolicyEvent(agent.session.snapshotEvents()), settingsRead.settings) : { state: "unprovable" };
3459
- if (policy.state === "unprovable") logger.warn("llm-fallbacks: agent \"%s\" subagent model-selection policy is on but unreadable — skipping role-inject", agent.id);
3460
- else {
3461
- const authorized = policy.state === "enabled" ? detectAuthorizedRoute(authorizedRouteView(ctx, agent)) : void 0;
3462
- if (authorized !== void 0) {
3463
- chainHeadMap.set(agent.id, {
3464
- at: Date.now(),
3465
- route: {
3466
- provider: authorized.provider,
3467
- model: authorized.model
3468
- },
3469
- source: "authorized"
3470
- });
3471
- logger.info("llm-fallbacks: agent \"%s\" authorized child route %s/%s is the chain head — skipping role-inject", agent.id, authorized.provider, authorized.model);
3472
- } else {
3473
- const role = await resolveRoleAtDispatch(agent, config.roles.rules, roleIds, {
3474
- automatchEnabled: config.roleAutoMatch ?? true,
3475
- automatch: (agent) => pickRoleByLlm(ctx, config.roles, agent, { warn: logger.warn }),
3476
- dispatchRole: subagentSeam.records.get(agent.id)?.role,
3477
- warn: logger.warn,
3478
- servedRoute: servedRouteFor(agent, config)
3479
- });
3480
- if (role !== "inherit") {
3481
- dispatchRoles.set(agent.id, role);
3482
- const { all, wildcard } = resolveChainViews(config.roles.list, config.rootChain, role, seed.provider, seed.model, logger.warn);
3483
- let to;
3484
- if (policy.state === "enabled") {
3485
- const routes = resolvedRoutes(all);
3486
- to = firstAllowedCandidate(routes, policy.allowedModels);
3487
- if (to === void 0 && routes.length > 0) logger.warn("llm-fallbacks: agent \"%s\" role-inject candidates are all outside the subagent allowlist — skipping inject (host seed stands)", agent.id);
3488
- } else {
3489
- const head = firstExactCandidate(all, wildcard);
3490
- if (head !== void 0 && head.model !== void 0) to = {
3491
- provider: head.provider,
3492
- model: head.model
3493
- };
3494
- }
3495
- if (to !== void 0 && !(to.provider === seed.provider && to.model === seed.model)) {
3496
- if (policy.state === "enabled") chainHeadMap.set(agent.id, {
3497
- at: Date.now(),
3498
- route: {
3499
- provider: to.provider,
3500
- model: to.model
3501
- },
3502
- source: "injected"
3503
- });
3504
- logger.info("llm-fallbacks: agent \"%s\" role-inject role=%s model=%s/%s", agent.id, role, to.provider, to.model);
3505
- return overrideConfig(seed, to);
3506
- }
3507
- }
3508
- }
3509
- }
3510
- } catch (error) {
3511
- logger.warn("llm-fallbacks: dispatch role-injection failed, proceeding with the request unchanged: %s", error?.message ?? String(error));
3512
- }
3513
- }
3514
- if (hasChains && isFallbackActive(config) && config.alwaysModeRetryCap > 0 && countRetryEvents(agent.session, turn, step, seed.provider) >= config.alwaysModeRetryCap) {
3515
- const decisionState = states.peek(agent.id);
3516
- const capRoute = anchorServedRoute(config, {
3517
- provider: seed.provider,
3518
- model: seed.model
3519
- }, new Date());
3520
- const pending = await decide(agent, turn, step, capRoute, "always-cap", decisionState);
3521
- if (pending !== null) {
3522
- const commitState = states.get(agent.id);
3523
- commit(commitState, pending, turn, step);
3524
- const appliedCap = states.applyPending(commitState, turn, step);
3525
- if (appliedCap !== void 0) return overrideConfig(seed, appliedCap.to);
3526
- } else failHalfOpenProbe(agent.id, capRoute);
3527
- }
3528
- return seed;
3529
- });
3530
- ctx.on("agent/status", ({ agent, status }) => {
3531
- if (status !== "idle") return;
3532
- const state = states.peek(agent.id);
3533
- if (state !== void 0) states.clearStepState(state);
3534
- });
3133
+ const walkDeps = {
3134
+ readConfig: () => source(),
3135
+ getLlm: () => ctx.get("llm"),
3136
+ states,
3137
+ slotWinners,
3138
+ log: logger
3139
+ };
3140
+ const reconcileFallbacksAdapter = installFallbacksAdapter(ctx, () => source(), (options) => walkChain(walkDeps, options));
3535
3141
  ctx.on("agent/disposed", ({ agent }) => {
3536
3142
  states.delete(agent.id);
3537
- dispatchInjected.delete(agent.id);
3538
- dispatchRoles.delete(agent.id);
3539
- slotWinners.delete(agent.id);
3540
- blockedAttemptMap.delete(agent.id);
3541
- chainHeadMap.delete(agent.id);
3143
+ const markerPrefix = slotMarkerPrefix(agent.id);
3144
+ for (const key of slotWinners.keys()) if (key.startsWith(markerPrefix)) slotWinners.delete(key);
3542
3145
  subagentSeam.records.delete(agent.id);
3543
- lastKnownPolicySettings.delete(agent.id);
3544
- });
3545
- ctx.on("session/event", (session, event) => {
3546
- if (event.type !== "assistant/message") return;
3547
- const config = source();
3548
- if ((config.recovery ?? "timer") !== "half-open") return;
3549
- if (event.data.interrupted === true) return;
3550
- const message = event.data.message;
3551
- if (message.source.kind !== "model") return;
3552
- const state = states.peek(session.id);
3553
- if (state === void 0) return;
3554
- const observed = anchorServedRoute(config, {
3555
- provider: message.source.provider,
3556
- model: message.source.model
3557
- }, new Date());
3558
- states.observeSuccess(state, selectorKey(observed.provider, observed.model));
3559
3146
  });
3560
3147
  ctx.effect(() => () => {
3561
3148
  states.clear();
3562
- dispatchInjected.clear();
3563
- dispatchRoles.clear();
3564
3149
  slotWinners.clear();
3565
- blockedAttemptMap.clear();
3566
- chainHeadMap.clear();
3567
- subagentSeam.records.clear();
3568
- subagentSeam.noticeEmitted.clear();
3569
- subagentSeam.dispose();
3570
- lastKnownPolicySettings.clear();
3571
3150
  }, "llm-fallbacks: clear per-agent state");
3572
3151
  const fallbacksCommandController = {
3573
3152
  getSnapshot(agent) {
3574
3153
  const config = source();
3575
- const role = rememberedRole(agent.id) ?? resolveRole(agent, config.roles.rules, roleIds, logger.warn, servedRouteFor(agent, config));
3576
3154
  const state = states.peek(agent.id);
3577
3155
  const recovery = config.recovery ?? "timer";
3578
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
+ }));
3579
3169
  return {
3580
3170
  origin: agent.session.header?.origin ?? "root",
3581
3171
  role,
3582
- ...resolveChainForDiagnostic(config.roles.list, config.rootChain, role, logger.warn),
3583
- slot: resolveSlotState(config, new Date(), config.tz ?? "Asia/Shanghai"),
3172
+ ...roleChain === void 0 ? {} : { roleChain },
3173
+ chains,
3584
3174
  switches: recentFallbacksSwitches(agent.session.snapshotEvents(), 5),
3585
3175
  cooldown: state === void 0 ? [] : recovery === "half-open" ? [...state.cooldown.snapshot(), ...state.recovery.halfOpenEntries().map((entry) => ({
3586
3176
  key: entry.key,
@@ -3594,32 +3184,29 @@ function apply(ctx, config = defaultFallbacksConfig) {
3594
3184
  return {
3595
3185
  active: isFallbackActive(config),
3596
3186
  triggerCodes: config.triggerCodes,
3597
- rootChain: config.rootChain,
3598
- timeSlots: (config.timeSlots ?? []).map((row) => row.kind === "preset" ? {
3599
- preset: row.preset,
3600
- chainCount: (row.chain ?? []).length
3601
- } : {
3602
- start: row.start,
3603
- end: row.end,
3604
- days: row.days,
3605
- chainCount: (row.chain ?? []).length
3606
- }),
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
+ })),
3607
3201
  tz: config.tz ?? "Asia/Shanghai",
3608
3202
  roles: config.roles.list.map((role) => ({
3609
3203
  id: role.id,
3610
- chainCount: role.chain?.length ?? 0
3611
- })),
3612
- rules: config.roles.rules.map((rule) => ({
3613
- provider: rule.provider ?? "",
3614
- model: rule.model ?? "",
3615
- role: rule.role
3204
+ chain: role.chain
3616
3205
  })),
3617
3206
  cooldownMs: config.cooldownMs,
3618
3207
  revertPolicy: config.revertPolicy,
3619
3208
  maxSwitchesPerStep: config.maxSwitchesPerStep,
3620
- alwaysModeRetryCap: config.alwaysModeRetryCap,
3621
- presets: config.presets ?? "bundled",
3622
- roleAutoMatch: config.roleAutoMatch ?? true
3209
+ presets: config.presets ?? "bundled"
3623
3210
  };
3624
3211
  },
3625
3212
  revertSeed: async (roleId) => {
@@ -3645,4 +3232,4 @@ function apply(ctx, config = defaultFallbacksConfig) {
3645
3232
  });
3646
3233
  }
3647
3234
  //#endregion
3648
- export { Config, FallbacksSeedManager, INHERIT_ROLE_ID, ROLE_ID_PATTERN, ROLE_PROJECTION_KEY, SelectorError, annotateCandidates, apply, blockedAttempts, chainHeads, countRetryEvents, 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 };