dsh-llm-fallbacks 0.6.13 → 0.7.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
@@ -338,9 +338,7 @@ const defaultFallbacksConfig = {
338
338
  cooldownMs: 3e5,
339
339
  revertPolicy: "cooldown-expiry",
340
340
  maxSwitchesPerStep: 8,
341
- alwaysModeRetryCap: 5,
342
341
  presets: "bundled",
343
- roleAutoMatch: true,
344
342
  timeSlots: [],
345
343
  tz: "Asia/Shanghai",
346
344
  recovery: "timer"
@@ -396,6 +394,8 @@ function validateFallbacksConfig(config, logger) {
396
394
  function detectLegacyKeys(source) {
397
395
  const keys = [];
398
396
  if (Object.hasOwn(source, "chains")) keys.push("chains");
397
+ if (Object.hasOwn(source, "alwaysModeRetryCap")) keys.push("alwaysModeRetryCap");
398
+ if (Object.hasOwn(source, "roleAutoMatch")) keys.push("roleAutoMatch");
399
399
  const roles = source.roles;
400
400
  if (isRecordLike(roles)) {
401
401
  if (Object.hasOwn(roles, "default")) keys.push("roles.default");
@@ -447,9 +447,7 @@ const Config = z.object({
447
447
  cooldownMs: z.number().default(3e5),
448
448
  revertPolicy: z.union([z.const("cooldown-expiry"), z.const("never")]).default("cooldown-expiry"),
449
449
  maxSwitchesPerStep: z.number().default(8),
450
- alwaysModeRetryCap: z.number().default(5),
451
450
  presets: z.union([z.const("bundled"), z.const("none")]).default("bundled"),
452
- roleAutoMatch: z.boolean().default(true),
453
451
  timeSlots: z.array(z.object({
454
452
  kind: z.string(),
455
453
  preset: z.string(),
@@ -461,98 +459,6 @@ const Config = z.object({
461
459
  tz: z.string().default("Asia/Shanghai"),
462
460
  recovery: z.union([z.const("timer"), z.const("half-open")]).default("timer")
463
461
  }).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
462
  //#endregion
557
463
  //#region .build/host/chains.js
558
464
  function resolveCandidate(entry, failing, modelExists) {
@@ -569,7 +475,7 @@ function resolveCandidate(entry, failing, modelExists) {
569
475
  }
570
476
  return selector;
571
477
  }
572
- function buildRoleEntries(roles, rootChain, role) {
478
+ function roleChainEntries(roles, rootChain, role) {
573
479
  if (role.trim() === "inherit") return rootChain;
574
480
  const roleDef = roles.find((declared) => declared.id.trim() === role.trim());
575
481
  if (roleDef === void 0) return rootChain;
@@ -580,7 +486,7 @@ function resolveChainViews(roles, rootChain, role, provider, model, warn = conso
580
486
  provider,
581
487
  model
582
488
  };
583
- const entries = buildRoleEntries(roles, rootChain, role);
489
+ const entries = roleChainEntries(roles, rootChain, role);
584
490
  if (role.trim() !== "inherit" && !roles.some((declared) => declared.id.trim() === role.trim())) warn(`llm-fallbacks: unknown role "${role}" — falling back to rootChain`);
585
491
  const all = [];
586
492
  const wildcard = [];
@@ -616,7 +522,7 @@ function resolveChain(roles, rootChain, role, provider, model, filter, modelExis
616
522
  return selectCandidates(all, wildcard, filter, modelExists);
617
523
  }
618
524
  function hasWildcardEntry(roles, rootChain, role) {
619
- const entries = buildRoleEntries(roles, rootChain, role);
525
+ const entries = roleChainEntries(roles, rootChain, role);
620
526
  for (const entry of entries) try {
621
527
  if (parseSelector(entry).model === void 0) return true;
622
528
  } catch {}
@@ -668,6 +574,25 @@ function annotateCandidates(candidates, surviving, options) {
668
574
  });
669
575
  }
670
576
  //#endregion
577
+ //#region .build/host/route-allowlist.js
578
+ function resolvedRoutes(candidates) {
579
+ const routes = [];
580
+ for (const candidate of candidates) {
581
+ if (candidate.model === void 0) continue;
582
+ routes.push({
583
+ provider: candidate.provider,
584
+ model: candidate.model
585
+ });
586
+ }
587
+ return routes;
588
+ }
589
+ function routeOnAllowlist(route, allowedModels) {
590
+ return allowedModels.some((allowed) => allowed.provider === route.provider && allowed.model === route.model);
591
+ }
592
+ function firstAllowedCandidate(resolved, allowedModels) {
593
+ return resolved.find((route) => routeOnAllowlist(route, allowedModels));
594
+ }
595
+ //#endregion
671
596
  //#region .build/host/roles.js
672
597
  function resolveRole(agent, rules, roleIds, warn = console.warn, servedRoute) {
673
598
  if ((agent.session?.header?.origin ?? "root") !== "subagent") return INHERIT_ROLE_ID;
@@ -691,7 +616,7 @@ function resolveRole(agent, rules, roleIds, warn = console.warn, servedRoute) {
691
616
  }
692
617
  //#endregion
693
618
  //#region .build/host/role-resolution.js
694
- async function resolveRoleAtDispatch(agent, rules, roleIds, opts) {
619
+ function resolveDeclaredRole(agent, rules, roleIds, opts) {
695
620
  const preset = agent.session?.header?.agentPreset?.trim();
696
621
  if (preset !== void 0 && preset !== "" && preset !== "inherit") {
697
622
  const declared = roleIds.get(preset);
@@ -702,67 +627,453 @@ async function resolveRoleAtDispatch(agent, rules, roleIds, opts) {
702
627
  const declared = roleIds.get(dispatch);
703
628
  if (declared !== void 0) return declared;
704
629
  }
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"`);
630
+ return resolveRole(agent, rules, roleIds, opts.warn, opts.servedRoute);
631
+ }
632
+ //#endregion
633
+ //#region .build/host/virtual-adapter.js
634
+ const FALLBACKS_PROVIDER$1 = "FallbacksChain";
635
+ const FALLBACKS_CHAIN_MODEL = "Auto";
636
+ function firstDispatchableExactHead(chain, allowedModels) {
637
+ for (const entry of chain) {
638
+ let selector;
639
+ try {
640
+ selector = parseSelector(entry);
641
+ } catch {
642
+ continue;
713
643
  }
644
+ if (selector.model === void 0) continue;
645
+ if (selector.provider === "FallbacksChain") continue;
646
+ const head = {
647
+ provider: selector.provider,
648
+ model: selector.model
649
+ };
650
+ if (allowedModels !== void 0 && !routeOnAllowlist(head, allowedModels)) continue;
651
+ return head;
714
652
  }
715
- return INHERIT_ROLE_ID;
716
653
  }
717
- function firstExactCandidate(all, wildcard) {
718
- for (let index = 0; index < all.length; index += 1) if (!wildcard[index]) return all[index];
654
+ function effectiveHeadOf(config, now) {
655
+ if (!isAllDayConforming(config.rootChain)) return void 0;
656
+ return firstDispatchableExactHead(resolveEffectiveChain(config, now, config.tz ?? "Asia/Shanghai"));
657
+ }
658
+ function pickerDisplayName(config, now = new Date(), modelDisplayName) {
659
+ const head = effectiveHeadOf(config, now);
660
+ if (head === void 0) return FALLBACKS_CHAIN_MODEL;
661
+ const slot = resolveSlotState(config, now, config.tz ?? "Asia/Shanghai");
662
+ const model = modelDisplayName !== void 0 && modelDisplayName !== "" ? modelDisplayName : head.model;
663
+ return `${FALLBACKS_CHAIN_MODEL}: ${model}[${slot.label}]`;
664
+ }
665
+ async function resolveHeadDisplayName(llm, head) {
666
+ if (llm === void 0) return head.model;
667
+ try {
668
+ const row = (await llm.listModels(head.provider)).find((model) => model.id === head.model);
669
+ if (row !== void 0 && row.name !== "") return row.name;
670
+ } catch {}
671
+ try {
672
+ const info = await llm.resolveModelInfo(head.provider, head.model);
673
+ if (info.name !== "") return info.name;
674
+ } catch {}
675
+ return head.model;
676
+ }
677
+ var FallbacksChainAdapter = class extends LlmAdapter {
678
+ readConfig;
679
+ getLlm;
680
+ walk;
681
+ constructor(readConfig, getLlm, walk) {
682
+ super();
683
+ this.readConfig = readConfig;
684
+ this.getLlm = getLlm;
685
+ this.walk = walk;
686
+ }
687
+ providerInfo(provider) {
688
+ return {
689
+ id: provider,
690
+ name: FALLBACKS_PROVIDER$1
691
+ };
692
+ }
693
+ async listModels(provider) {
694
+ const config = this.readConfig();
695
+ const now = new Date();
696
+ const head = effectiveHeadOf(config, now);
697
+ const display = head === void 0 ? void 0 : await resolveHeadDisplayName(this.getLlm(), head);
698
+ return [{
699
+ provider,
700
+ id: FALLBACKS_CHAIN_MODEL,
701
+ name: pickerDisplayName(config, now, display)
702
+ }];
703
+ }
704
+ async resolveModel(provider, model, signal) {
705
+ const head = effectiveHeadOf(this.readConfig(), new Date());
706
+ const llm = this.getLlm();
707
+ if (head !== void 0 && llm !== void 0) try {
708
+ const info = await llm.resolveModelInfo(head.provider, head.model, signal);
709
+ return {
710
+ provider,
711
+ id: model,
712
+ name: info.name,
713
+ ...info.description === void 0 ? {} : { description: info.description },
714
+ ...info.inputModalities === void 0 ? {} : { inputModalities: info.inputModalities },
715
+ ...info.context === void 0 ? {} : { context: info.context },
716
+ ...info.defaultMaxTokens === void 0 ? {} : { defaultMaxTokens: info.defaultMaxTokens },
717
+ ...info.reasoning === void 0 ? {} : { reasoning: info.reasoning }
718
+ };
719
+ } catch {}
720
+ return {
721
+ provider,
722
+ id: model,
723
+ name: model
724
+ };
725
+ }
726
+ providerRetryPolicy(_provider) {
727
+ try {
728
+ const head = effectiveHeadOf(this.readConfig(), new Date());
729
+ if (head === void 0 || head.provider === "FallbacksChain") return void 0;
730
+ return this.getLlm()?.providerRetryPolicy(head.provider);
731
+ } catch {
732
+ return;
733
+ }
734
+ }
735
+ imageRequestPricing(_provider, _model) {
736
+ try {
737
+ const head = effectiveHeadOf(this.readConfig(), new Date());
738
+ if (head === void 0 || head.provider === "FallbacksChain") return void 0;
739
+ return this.getLlm()?.imageRequestPricing(head.provider, head.model);
740
+ } catch {
741
+ return;
742
+ }
743
+ }
744
+ stream(options) {
745
+ return this.walk(options);
746
+ }
747
+ };
748
+ function installFallbacksAdapter(ctx, readConfig, walk) {
749
+ let llm;
750
+ let disposeAdapter;
751
+ let registered = false;
752
+ const logger = ctx.logger("llm-fallbacks");
753
+ const adapter = new FallbacksChainAdapter(readConfig, () => llm, walk);
754
+ const reconcile = () => {
755
+ const shouldRegister = isFallbackActive(readConfig());
756
+ if (shouldRegister && !registered) {
757
+ if (llm === void 0) return;
758
+ try {
759
+ disposeAdapter = llm.registerAdapter([FALLBACKS_PROVIDER$1], adapter);
760
+ registered = true;
761
+ logger.info("llm-fallbacks: virtual adapter registered — FallbacksChain/Auto is selectable in the model picker");
762
+ } catch (error) {
763
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "DUPLICATE_ADAPTER") throw error;
764
+ logger.debug("llm-fallbacks: virtual adapter already registered — no route on this fiber (multi-fiber dedupe)");
765
+ }
766
+ } else if (!shouldRegister && registered) {
767
+ disposeAdapter?.();
768
+ disposeAdapter = void 0;
769
+ registered = false;
770
+ logger.info("llm-fallbacks: virtual adapter unregistered — FallbacksChain/Auto hidden from the model picker");
771
+ }
772
+ };
773
+ ctx.inject(["llm"], (llmCtx) => {
774
+ llm = llmCtx.llm;
775
+ reconcile();
776
+ return () => {
777
+ disposeAdapter?.();
778
+ disposeAdapter = void 0;
779
+ registered = false;
780
+ llm = void 0;
781
+ };
782
+ });
783
+ return reconcile;
719
784
  }
720
785
  //#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;
786
+ //#region .build/host/chain-walk.js
787
+ const EMPTY_EFFECTIVE_CHAIN_CODE = "EMPTY_EFFECTIVE_CHAIN";
788
+ const UNDISPATCHABLE_HEAD_CODE = "UNDISPATCHABLE_EFFECTIVE_HEAD";
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));
822
+ }
823
+ function envelopeProvenance(replayState) {
824
+ if (typeof replayState !== "object" || replayState === null) return void 0;
825
+ const response = replayState.response;
826
+ if (typeof response !== "object" || response === null) return void 0;
827
+ const { provider, model } = response;
828
+ if (typeof provider !== "string" || provider === "" || provider === "FallbacksChain") return void 0;
829
+ if (typeof model !== "string" || model === "") return void 0;
724
830
  return {
725
- provider: source.provider,
726
- model: source.model
831
+ provider,
832
+ model
727
833
  };
728
834
  }
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
- }
835
+ function restoreEnvelopeProvenance(messages) {
836
+ let changed = false;
837
+ const restored = messages.map((message) => {
838
+ const source = message.source;
839
+ if (message.role !== "assistant" || source?.kind !== "model") return message;
840
+ if (source.provider !== "FallbacksChain" || source.model !== "Auto") return message;
841
+ const provenance = envelopeProvenance(source.replayState);
842
+ if (provenance === void 0) return message;
843
+ changed = true;
844
+ return {
845
+ ...message,
846
+ source: {
847
+ ...source,
848
+ ...provenance
849
+ }
850
+ };
851
+ });
852
+ return changed ? restored : messages;
737
853
  }
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;
854
+ function agentLikeOf(view) {
855
+ return {
856
+ options: view?.optionsRoute,
857
+ session: { header: {
858
+ origin: view?.origin ?? "root",
859
+ agentPreset: view?.agentPreset
860
+ } }
861
+ };
747
862
  }
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({
863
+ function servedRouteOf(config, route, now) {
864
+ if (route?.provider !== "FallbacksChain" || route.model !== "Auto") return void 0;
865
+ const head = effectiveHeadOf(config, now);
866
+ return head === void 0 ? void 0 : {
867
+ provider: head.provider,
868
+ model: head.model
869
+ };
870
+ }
871
+ function slotWinnerKey(winner) {
872
+ if (winner === "all-day") return "all-day";
873
+ return winner.kind === "preset" ? `preset:${winner.preset}` : `custom:${winner.start}-${winner.end}`;
874
+ }
875
+ function logSlotRotation(deps, start) {
876
+ const { config, now, origin, owner } = start;
877
+ const markers = deps.slotWinners;
878
+ if (markers === void 0 || owner === void 0 || origin !== "root") return;
879
+ if (!isAllDayConforming(config.rootChain) || (config.timeSlots?.length ?? 0) === 0) return;
880
+ const slot = resolveSlotState(config, now, config.tz ?? "Asia/Shanghai");
881
+ const key = slotWinnerKey(slot.winner);
882
+ const previous = markers.get(owner);
883
+ if (previous !== void 0 && previous.key !== key) deps.log.info("llm-fallbacks: agent \"%s\" time-slot switch (分时切换): %s -> %s", owner, previous.label, slot.label);
884
+ markers.set(owner, {
885
+ key,
886
+ label: slot.label
887
+ });
888
+ }
889
+ function resolveWalk(deps, options) {
890
+ const config = deps.readConfig();
891
+ const now = new Date();
892
+ const view = deps.resolveAgentView(options.sessionId);
893
+ const owner = options.sessionId;
894
+ const seam = owner === void 0 ? void 0 : deps.seamRecord(owner);
895
+ const role = resolveDeclaredRole(agentLikeOf(view), config.roles.rules, deps.roleIds(), {
896
+ dispatchRole: seam?.role,
897
+ servedRoute: servedRouteOf(config, view?.optionsRoute, now),
898
+ warn: deps.log.warn
899
+ });
900
+ const origin = view?.origin ?? "root";
901
+ const rootTail = origin === "subagent" ? config.rootChain : resolveEffectiveChain(config, now, config.tz ?? "Asia/Shanghai");
902
+ const entries = roleChainEntries(config.roles.list, rootTail, role);
903
+ if (entries.length === 0) throw new LlmError("llm-fallbacks: the effective chain is empty — the virtual FallbacksChain route has no head to delegate to", EMPTY_EFFECTIVE_CHAIN_CODE);
904
+ if (origin === "root" && !isAllDayConforming(config.rootChain)) throw new LlmError("llm-fallbacks: the all-day chain is not conforming (exactly one official model required) — refusing to delegate", UNDISPATCHABLE_HEAD_CODE);
905
+ const policy = owner !== void 0 && seam?.virtualRouteInjected === true ? deps.policyFor(owner) : void 0;
906
+ if (policy?.state === "unprovable") throw new LlmError("llm-fallbacks: the subagent model-selection policy is unprovable — refusing to delegate the FallbacksChain call", UNDISPATCHABLE_HEAD_CODE);
907
+ const dispatchable = firstDispatchableExactHead(entries);
908
+ const allowedModels = policy?.state === "enabled" ? policy.allowedModels : void 0;
909
+ const head = allowedModels === void 0 ? dispatchable : firstDispatchableExactHead(entries, allowedModels);
910
+ if (head === void 0) throw new LlmError(dispatchable === void 0 ? "llm-fallbacks: the effective chain head cannot be dispatched (wildcard, malformed, or self-route) — refusing to delegate" : "llm-fallbacks: every dispatchable route of the effective chain is outside the subagent allowlist — refusing to delegate", UNDISPATCHABLE_HEAD_CODE);
911
+ const llm = deps.getLlm();
912
+ if (llm === void 0) throw new LlmError("llm-fallbacks: the llm runtime is unavailable — cannot delegate the FallbacksChain call", LLM_UNAVAILABLE_CODE);
913
+ return {
914
+ config,
915
+ now,
916
+ origin,
917
+ role,
918
+ rootTail,
919
+ head,
920
+ llm,
921
+ owner,
922
+ seam
923
+ };
924
+ }
925
+ function walkChain(deps, options) {
926
+ return walk(deps, resolveWalk(deps, options), options);
927
+ }
928
+ async function* walk(deps, start, options) {
929
+ const { config, role, rootTail, head, llm, owner } = start;
930
+ logSlotRotation(deps, start);
931
+ const messages = restoreEnvelopeProvenance(options.messages);
932
+ const stateOf = () => owner === void 0 ? void 0 : deps.states.peek(owner);
933
+ const failedKeys = new Set();
934
+ const isCooled = (key) => {
935
+ const state = stateOf();
936
+ return state !== void 0 && deps.states.isSuppressed(state, key, Date.now(), config.recovery ?? "timer");
937
+ };
938
+ const coolRoute = (failed) => {
939
+ if (owner === void 0) return;
940
+ const state = deps.states.get(owner);
941
+ const key = selectorKey(failed.provider, failed.model);
942
+ 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;
943
+ deps.states.suppress(state, key, until);
944
+ };
945
+ const bookFailure = (failed, code) => {
946
+ failedKeys.add(selectorKey(failed.provider, failed.model));
947
+ if (!REQUEST_SCOPED_CODES.has(code)) coolRoute(failed);
948
+ };
949
+ const advanceFrom = async (from, failureCode, blockedReason) => {
950
+ const { all, wildcard } = resolveChainViews(config.roles.list, rootTail, role, from.provider, from.model, deps.log.warn);
951
+ const own = all.map((candidate) => candidate.provider !== FALLBACKS_PROVIDER$1);
952
+ const candidates = all.filter((_, index) => own[index]);
953
+ const wildcards = wildcard.filter((_, index) => own[index]);
954
+ const exists = hasWildcardEntry(config.roles.list, rootTail, role) ? await makeModelExists(llm, [...new Set(candidates.map((candidate) => candidate.provider))]) : void 0;
955
+ const contextWindowOf = failureCode === "CONTEXT_WINDOW_EXCEEDED" ? await makeContextWindowOf(llm, [from, ...candidates.flatMap((candidate) => candidate.model === void 0 ? [] : [{
755
956
  provider: candidate.provider,
756
957
  model: candidate.model
958
+ }])]) : void 0;
959
+ const minContextWindow = contextWindowOf?.(from.provider, from.model);
960
+ const cooldown = { isSuppressed: (key) => isCooled(key) };
961
+ const failed = { has: (key) => failedKeys.has(key) };
962
+ const surviving = selectCandidates(candidates, wildcards, createCandidateFilter({
963
+ current: from,
964
+ cooldown,
965
+ failed,
966
+ minContextWindow,
967
+ contextWindowOf
968
+ }), exists);
969
+ const annotated = annotateCandidates(candidates, surviving, {
970
+ current: from,
971
+ cooldown,
972
+ failed,
973
+ minContextWindow,
974
+ contextWindowOf
757
975
  });
976
+ const describeCandidates = (list) => list.map(({ candidate, skip }) => skip === void 0 ? `${candidate.provider}/${candidate.model}` : `${candidate.provider}/${candidate.model} (skipped: ${skip})`);
977
+ if (start.seam?.virtualRouteInjected === true && owner !== void 0) {
978
+ const policy = deps.policyFor(owner);
979
+ if (policy.state === "unprovable") {
980
+ deps.log.warn("llm-fallbacks: agent \"%s\" subagent model-selection policy is on but unreadable — no fallback switch (a cooled-head re-entry is refused)", owner);
981
+ return {
982
+ candidates: describeCandidates(annotated),
983
+ currentAdmitted: false
984
+ };
985
+ }
986
+ if (policy.state === "enabled") {
987
+ const currentAdmitted = routeOnAllowlist(from, policy.allowedModels);
988
+ const permitted = new Set(surviving.filter((candidate) => candidate.model !== void 0 && routeOnAllowlist({
989
+ provider: candidate.provider,
990
+ model: candidate.model
991
+ }, policy.allowedModels)).map((candidate) => selectorKey(candidate.provider, candidate.model)));
992
+ const labelled = annotated.map((entry) => entry.skip === void 0 && !permitted.has(selectorKey(entry.candidate.provider, entry.candidate.model)) ? {
993
+ candidate: entry.candidate,
994
+ skip: "allowlist"
995
+ } : entry);
996
+ const allowed = firstAllowedCandidate(resolvedRoutes(surviving), policy.allowedModels);
997
+ if (allowed === void 0) {
998
+ const wouldBe = surviving[0];
999
+ if (wouldBe?.model !== void 0) {
1000
+ deps.log.warn("llm-fallbacks: agent \"%s\" fallback switch %s/%s blocked: resolved candidates are all outside the subagent allowlist — no switch", owner, wouldBe.provider, wouldBe.model);
1001
+ if (blockedReason !== void 0) deps.recordBlocked?.(owner, {
1002
+ provider: wouldBe.provider,
1003
+ model: wouldBe.model
1004
+ }, blockedReason);
1005
+ }
1006
+ return {
1007
+ candidates: describeCandidates(labelled),
1008
+ currentAdmitted
1009
+ };
1010
+ }
1011
+ return {
1012
+ target: allowed,
1013
+ candidates: describeCandidates(labelled),
1014
+ currentAdmitted
1015
+ };
1016
+ }
1017
+ }
1018
+ const first = surviving[0];
1019
+ if (first?.model === void 0) return { candidates: describeCandidates(annotated) };
1020
+ return {
1021
+ target: {
1022
+ provider: first.provider,
1023
+ model: first.model
1024
+ },
1025
+ candidates: describeCandidates(annotated)
1026
+ };
1027
+ };
1028
+ let target;
1029
+ if (isCooled(selectorKey(head.provider, head.model))) {
1030
+ const advance = await advanceFrom(head);
1031
+ if (advance?.target !== void 0) target = advance.target;
1032
+ else if (advance?.currentAdmitted === false) throw new LlmError("llm-fallbacks: the subagent model-selection policy no longer admits the chain head — refusing to delegate the FallbacksChain call", UNDISPATCHABLE_HEAD_CODE);
1033
+ else target = head;
1034
+ } else target = head;
1035
+ let switches = 0;
1036
+ for (;;) {
1037
+ const key = selectorKey(target.provider, target.model);
1038
+ const state = stateOf();
1039
+ if ((config.recovery ?? "timer") === "half-open" && state !== void 0 && state.recovery.tryMarkProbeLogged(key)) deps.log.info("llm-fallbacks: agent \"%s\" half-open probe %s/%s (role=%s, reason=%s)", owner ?? "", target.provider, target.model, role, "trigger-code");
1040
+ let committed = false;
1041
+ let swallowed;
1042
+ for await (const chunk of llm.stream({
1043
+ ...options,
1044
+ provider: target.provider,
1045
+ model: target.model,
1046
+ messages
1047
+ })) {
1048
+ const failure = chunk.type === "finish" && chunk.reason.kind === "error" ? chunk.reason.failure : void 0;
1049
+ if (failure !== void 0 && !committed && config.triggerCodes.includes(failure.code)) {
1050
+ swallowed = {
1051
+ chunk,
1052
+ code: failure.code
1053
+ };
1054
+ break;
1055
+ }
1056
+ if (chunk.type === "finish") {
1057
+ if (failure === void 0) {
1058
+ if (chunk.reason.kind !== "aborted" && state !== void 0) deps.states.observeSuccess(state, key);
1059
+ } else if (config.triggerCodes.includes(failure.code)) bookFailure(target, failure.code);
1060
+ yield chunk;
1061
+ return;
1062
+ }
1063
+ yield chunk;
1064
+ committed = true;
1065
+ }
1066
+ if (swallowed === void 0) return;
1067
+ bookFailure(target, swallowed.code);
1068
+ const next = switches < config.maxSwitchesPerStep ? await advanceFrom(target, swallowed.code, "trigger-code") : void 0;
1069
+ if (next === void 0 || next.target === void 0) {
1070
+ yield swallowed.chunk;
1071
+ return;
1072
+ }
1073
+ switches += 1;
1074
+ deps.log.info("llm-fallbacks: agent \"%s\" fallback switch (降级切换) %s/%s -> %s/%s (role=%s, reason=%s, candidates=%o)", owner ?? "", target.provider, target.model, next.target.provider, next.target.model, role, "trigger-code", next.candidates);
1075
+ target = next.target;
758
1076
  }
759
- return routes;
760
- }
761
- function routeOnAllowlist(route, allowedModels) {
762
- return allowedModels.some((allowed) => allowed.provider === route.provider && allowed.model === route.model);
763
- }
764
- function firstAllowedCandidate(resolved, allowedModels) {
765
- return resolved.find((route) => routeOnAllowlist(route, allowedModels));
766
1077
  }
767
1078
  //#endregion
768
1079
  //#region .build/host/subagent-policy.js
@@ -778,6 +1089,16 @@ function parseRoutes(value) {
778
1089
  }
779
1090
  return routes;
780
1091
  }
1092
+ function policySettingsFrom(value) {
1093
+ if (typeof value !== "object" || value === null) return void 0;
1094
+ if (!("enabled" in value) || typeof value.enabled !== "boolean") return void 0;
1095
+ const routes = "allowedModels" in value ? parseRoutes(value.allowedModels) : void 0;
1096
+ if (routes === void 0) return void 0;
1097
+ return {
1098
+ enabled: value.enabled,
1099
+ allowedModels: routes
1100
+ };
1101
+ }
781
1102
  function readSessionPolicyEvent(events) {
782
1103
  const event = events.find((candidate) => candidate.type === "subagent/model-selection-policy");
783
1104
  if (event === void 0) return {
@@ -1086,7 +1407,8 @@ function seamStateFor(root) {
1086
1407
  if (existing !== void 0) return existing;
1087
1408
  const created = {
1088
1409
  records: new Map(),
1089
- noticeEmitted: new Set()
1410
+ noticeEmitted: new Set(),
1411
+ fibers: 0
1090
1412
  };
1091
1413
  subagentSeamStates.set(root, created);
1092
1414
  return created;
@@ -1105,6 +1427,7 @@ function installSubagentSeam(ctx, options) {
1105
1427
  const pending = new Map();
1106
1428
  const wrappers = new WeakMap();
1107
1429
  const disposers = [];
1430
+ let disposed = false;
1108
1431
  let unreadableServiceLogged = false;
1109
1432
  let noRoleLogged = false;
1110
1433
  const debug = (message) => {
@@ -1115,15 +1438,60 @@ function installSubagentSeam(ctx, options) {
1115
1438
  const reportDegrade = (error) => {
1116
1439
  debug(`llm-fallbacks: subagent role seam degraded to the native path (start unaffected): ${errorMessage$1(error)}`);
1117
1440
  };
1118
- const recordChild = (childId, role, at, personaMissed) => {
1441
+ const recordChild = (childId, role, at, verdict = {}) => {
1119
1442
  const existing = records.get(childId);
1120
1443
  records.set(childId, {
1121
1444
  role,
1122
1445
  at,
1123
1446
  firstNoticePending: existing?.firstNoticePending ?? true,
1124
- ...existing?.personaNotApplied === true || personaMissed === true ? { personaNotApplied: true } : {}
1447
+ ...existing?.personaNotApplied === true || verdict.personaMissed === true ? { personaNotApplied: true } : {},
1448
+ ...existing?.virtualRouteInjected === true || verdict.virtualRouteInjected === true ? { virtualRouteInjected: true } : {}
1125
1449
  });
1126
1450
  };
1451
+ const explicitRoute = (request) => {
1452
+ const provider = request?.agentOptions?.provider;
1453
+ const model = request?.agentOptions?.model;
1454
+ return typeof provider === "string" && provider !== "" && typeof model === "string" && model !== "" ? {
1455
+ provider,
1456
+ model
1457
+ } : void 0;
1458
+ };
1459
+ const injectRoute = (spec, role, agentId) => {
1460
+ try {
1461
+ if (options.source === void 0) return void 0;
1462
+ const agentOptions = spec.request?.agentOptions;
1463
+ if (typeof agentOptions?.provider === "string" || typeof agentOptions?.model === "string") return void 0;
1464
+ const config = options.source();
1465
+ if (!isFallbackActive(config)) return void 0;
1466
+ const policy = options.policyFor?.(agentId);
1467
+ if (policy?.state === "unprovable") {
1468
+ debug(`llm-fallbacks: subagent seam — model-selection policy is unprovable for "${agentId}"; not mounting the virtual chain route`);
1469
+ return;
1470
+ }
1471
+ const chain = roleChainEntries(config.roles.list, config.rootChain, role);
1472
+ const allowedModels = policy?.state === "enabled" ? policy.allowedModels : void 0;
1473
+ const dispatchable = firstDispatchableExactHead(chain);
1474
+ const head = allowedModels === void 0 ? dispatchable : firstDispatchableExactHead(chain, allowedModels);
1475
+ if (head === void 0) {
1476
+ if (allowedModels !== void 0 && dispatchable !== void 0) debug(`llm-fallbacks: subagent seam — no route of role "${role}" is on the subagent allowlist; not mounting the virtual chain route`);
1477
+ return;
1478
+ }
1479
+ return {
1480
+ request: {
1481
+ ...spec.request,
1482
+ agentOptions: {
1483
+ ...agentOptions,
1484
+ provider: FALLBACKS_PROVIDER$1,
1485
+ model: FALLBACKS_CHAIN_MODEL
1486
+ }
1487
+ },
1488
+ head
1489
+ };
1490
+ } catch (error) {
1491
+ reportDegrade(error);
1492
+ return;
1493
+ }
1494
+ };
1127
1495
  const beginCorrelation = (spec, role, at) => {
1128
1496
  const parentSessionId = parentSessionIdOf(spec?.request);
1129
1497
  const label = typeof spec?.label === "string" ? spec.label : void 0;
@@ -1218,11 +1586,26 @@ function installSubagentSeam(ctx, options) {
1218
1586
  try {
1219
1587
  if (isLocalBackend(service, spec.provider)) {
1220
1588
  reservedId = spec.childId ?? randomUUID();
1221
- if (reservedId !== spec.childId) effectiveSpec = {
1589
+ const authorized = explicitRoute(spec.request);
1590
+ const injected = injectRoute(spec, role, reservedId);
1591
+ effectiveSpec = injected === void 0 ? reservedId === spec.childId ? spec : {
1222
1592
  ...spec,
1223
1593
  childId: reservedId
1594
+ } : {
1595
+ ...spec,
1596
+ childId: reservedId,
1597
+ request: injected.request
1224
1598
  };
1225
- recordChild(reservedId, role, at);
1599
+ recordChild(reservedId, role, at, { virtualRouteInjected: injected !== void 0 });
1600
+ const head = injected?.head ?? authorized;
1601
+ if (head !== void 0) options.chainHeads?.set(reservedId, {
1602
+ at,
1603
+ route: {
1604
+ provider: head.provider,
1605
+ model: head.model
1606
+ },
1607
+ source: injected === void 0 ? "authorized" : "injected"
1608
+ });
1226
1609
  }
1227
1610
  } catch (error) {
1228
1611
  if (reservedId !== void 0) records.delete(reservedId);
@@ -1252,7 +1635,7 @@ function installSubagentSeam(ctx, options) {
1252
1635
  try {
1253
1636
  observeStartResult(result, (childId) => {
1254
1637
  endCorrelation(pendingKey);
1255
- recordChild(childId, role, at, reservationPersonaMissed);
1638
+ recordChild(childId, role, at, { personaMissed: reservationPersonaMissed });
1256
1639
  }, () => {}, () => {
1257
1640
  endCorrelation(pendingKey);
1258
1641
  if (reservedId !== void 0) records.delete(reservedId);
@@ -1266,8 +1649,19 @@ function installSubagentSeam(ctx, options) {
1266
1649
  wrappers.set(value, wrapper);
1267
1650
  return wrapper;
1268
1651
  };
1652
+ const installSurface = (register) => {
1653
+ try {
1654
+ disposers.push(register());
1655
+ } catch (error) {
1656
+ for (const withdraw of disposers.splice(0)) try {
1657
+ withdraw();
1658
+ } catch {}
1659
+ if (ownsWrapper) subagentSeamRoots.delete(root);
1660
+ throw error;
1661
+ }
1662
+ };
1269
1663
  if (ownsWrapper) {
1270
- disposers.push(ctx.on(SUBAGENT_SEAM_EVENT, (_readCtx, name, _error, next) => {
1664
+ installSurface(() => ctx.on(SUBAGENT_SEAM_EVENT, (_readCtx, name, _error, next) => {
1271
1665
  const value = next();
1272
1666
  if (name !== "subagents") return value;
1273
1667
  try {
@@ -1277,7 +1671,7 @@ function installSubagentSeam(ctx, options) {
1277
1671
  return value;
1278
1672
  }
1279
1673
  }));
1280
- disposers.push(ctx.on("session/event", (session, event) => {
1674
+ installSurface(() => ctx.on("session/event", (session, event) => {
1281
1675
  try {
1282
1676
  if (event.type !== SUBAGENT_CATALOG_EVENT) return;
1283
1677
  const data = event.data;
@@ -1295,21 +1689,39 @@ function installSubagentSeam(ctx, options) {
1295
1689
  }
1296
1690
  }));
1297
1691
  }
1298
- disposers.push(installRoleNotice(ctx, {
1692
+ installSurface(() => installRoleNotice(ctx, {
1299
1693
  records,
1300
1694
  emitted: noticeEmitted,
1301
1695
  debug
1302
1696
  }));
1303
- disposers.push(installRoleProjection(ctx, { debug }));
1697
+ installSurface(() => installRoleProjection(ctx, { debug }));
1698
+ state.fibers += 1;
1699
+ const dispose = () => {
1700
+ if (disposed) return;
1701
+ disposed = true;
1702
+ if (ownsWrapper) subagentSeamRoots.delete(root);
1703
+ try {
1704
+ for (const withdraw of disposers.splice(0)) withdraw();
1705
+ } finally {
1706
+ state.fibers -= 1;
1707
+ if (state.fibers === 0) {
1708
+ state.records.clear();
1709
+ state.noticeEmitted.clear();
1710
+ }
1711
+ }
1712
+ };
1304
1713
  const seam = {
1305
1714
  records,
1306
1715
  noticeEmitted,
1307
1716
  ownsWrapper,
1308
- dispose: () => {
1309
- if (ownsWrapper) subagentSeamRoots.delete(root);
1310
- for (const dispose of disposers.splice(0)) dispose();
1311
- }
1717
+ dispose
1312
1718
  };
1719
+ try {
1720
+ ctx.effect(() => () => dispose(), "llm-fallbacks: subagent seam teardown");
1721
+ } catch (error) {
1722
+ dispose();
1723
+ throw error;
1724
+ }
1313
1725
  subagentSeamStores.set(ctx, seam);
1314
1726
  return seam;
1315
1727
  }
@@ -1440,21 +1852,6 @@ var CooldownStore = class {
1440
1852
  return active;
1441
1853
  }
1442
1854
  };
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
1855
  //#endregion
1459
1856
  //#region .build/host/state.js
1460
1857
  var FallbackStateStore = class {
@@ -1472,12 +1869,6 @@ var FallbackStateStore = class {
1472
1869
  let state = this.states.get(agentId);
1473
1870
  if (state === void 0) {
1474
1871
  state = {
1475
- stepFailures: {
1476
- turn: 0,
1477
- step: 0,
1478
- failed: new StepFailureSet(),
1479
- switchCount: 0
1480
- },
1481
1872
  cooldown: new CooldownStore(),
1482
1873
  recovery: new RecoveryStore()
1483
1874
  };
@@ -1491,42 +1882,6 @@ var FallbackStateStore = class {
1491
1882
  clear() {
1492
1883
  this.states.clear();
1493
1884
  }
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
1885
  suppress(state, key, untilEpochMs) {
1531
1886
  state.cooldown.suppress(key, untilEpochMs);
1532
1887
  }
@@ -1549,27 +1904,6 @@ var FallbackStateStore = class {
1549
1904
  }
1550
1905
  };
1551
1906
  //#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
1907
  //#region .build/host/gateway.js
1574
1908
  const FALLBACKS_PROFILE_ENTRY = "llm-fallbacks";
1575
1909
  const SUBAGENT_POLICY_SETTINGS_NS = "subagent-model-selection-settings";
@@ -1584,9 +1918,7 @@ const CONFIG_KEYS = {
1584
1918
  cooldownMs: true,
1585
1919
  revertPolicy: true,
1586
1920
  maxSwitchesPerStep: true,
1587
- alwaysModeRetryCap: true,
1588
1921
  presets: true,
1589
- roleAutoMatch: true,
1590
1922
  timeSlots: true,
1591
1923
  tz: true,
1592
1924
  recovery: true
@@ -1940,10 +2272,8 @@ const FALLBACKS_COMMAND_LOCALES = {
1940
2272
  configRules: "角色规则",
1941
2273
  configCooldown: "冷却",
1942
2274
  configRevert: "回主策略",
1943
- configMaxSwitches: "单步最大切换",
1944
- configAlwaysCap: "always 上限",
2275
+ configMaxSwitches: "单次请求行走最大切换",
1945
2276
  configPresets: "预置",
1946
- configRoleAutoMatch: "角色自动匹配",
1947
2277
  configEdit: "编辑:/settings(TUI 设置界面)或 ~/.dsh/profiles/<profile>/cordis.patch.yml(插件行)/ $DSH_HOME/settings.yaml(fallbacks: 分节)",
1948
2278
  configEditHint: "TUI 通过 /settings 修改配置;文件编辑仍然可用",
1949
2279
  revertSeedOk: "角色 {id} 已还原为 Seed 默认",
@@ -1995,10 +2325,8 @@ const FALLBACKS_COMMAND_LOCALES = {
1995
2325
  configRules: "Rules",
1996
2326
  configCooldown: "Cooldown",
1997
2327
  configRevert: "Revert",
1998
- configMaxSwitches: "Max switches/step",
1999
- configAlwaysCap: "Always-mode cap",
2328
+ configMaxSwitches: "Max switches per request walk",
2000
2329
  configPresets: "Presets",
2001
- configRoleAutoMatch: "Auto-match",
2002
2330
  configEdit: "Edit: /settings (TUI settings screen) or ~/.dsh/profiles/<profile>/cordis.patch.yml (plugin row) / $DSH_HOME/settings.yaml (fallbacks: section)",
2003
2331
  configEditHint: "TUI edits config via /settings; file editing still works",
2004
2332
  revertSeedOk: "role {id} reverted to its seed default",
@@ -2149,9 +2477,7 @@ function fallbacksConfigText(summary, locale = "zh") {
2149
2477
  `${t.configCooldown}: ${summary.cooldownMs} ms`,
2150
2478
  `${t.configRevert}: ${summary.revertPolicy}`,
2151
2479
  `${t.configMaxSwitches}: ${summary.maxSwitchesPerStep}`,
2152
- `${t.configAlwaysCap}: ${summary.alwaysModeRetryCap}`,
2153
2480
  `${t.configPresets}: ${summary.presets}`,
2154
- `${t.configRoleAutoMatch}: ${summary.roleAutoMatch ? t.configEnabled : t.configDisabled}`,
2155
2481
  "",
2156
2482
  t.configEdit,
2157
2483
  t.configEditHint
@@ -2508,7 +2834,7 @@ function fallbacksChildren(canonicalPath) {
2508
2834
  if (canonicalPath.length === 2 && canonicalPath[1] === "config") return [REVERT_SEED_NODE];
2509
2835
  return [];
2510
2836
  }
2511
- const FALLBACKS_PROVIDER$1 = {
2837
+ const FALLBACKS_PROVIDER = {
2512
2838
  root: FALLBACKS_TUI_ROOT,
2513
2839
  descriptions: {
2514
2840
  zh: FALLBACKS_COMMAND_LOCALES.zh.description,
@@ -2522,7 +2848,7 @@ function installTuiClient(ctx, opts) {
2522
2848
  const trees = tctx.tuiCommandTrees;
2523
2849
  if (trees === void 0) return;
2524
2850
  try {
2525
- return trees.register(FALLBACKS_PROVIDER$1);
2851
+ return trees.register(FALLBACKS_PROVIDER);
2526
2852
  } catch (error) {
2527
2853
  if (!(error instanceof Error) || !error.message.includes("already registered")) throw error;
2528
2854
  tctx.logger("llm-fallbacks").debug("llm-fallbacks: tui command tree already registered — no provider on this fiber");
@@ -2593,8 +2919,8 @@ function buildFallbacksTuiSection() {
2593
2919
  ns: FALLBACKS_TUI_SECTION_NS,
2594
2920
  title: "fallbacks",
2595
2921
  descriptions: {
2596
- zh: "回退设置:降级链、分时切换、角色与高级选项(与 Web 设置卡片完全一致)。",
2597
- en: "Fallback settings: degradation chains, time slots, roles, and advanced options (full parity with the web settings card)."
2922
+ zh: "回退设置:降级链、分时切换、角色与高级选项(与 Web 设置卡片完全一致)。仅选中 FallbacksChain/Auto 的会话会执行降级链;选择真实模型的会话没有插件降级。",
2923
+ en: "Fallback settings: degradation chains, time slots, roles, and advanced options (full parity with the web settings card). Only sessions on FallbacksChain/Auto walk a chain — a real-model selection gets no plugin fallback."
2598
2924
  },
2599
2925
  groups: [
2600
2926
  {
@@ -2631,16 +2957,6 @@ function buildFallbacksTuiSection() {
2631
2957
  }
2632
2958
  ],
2633
2959
  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
2960
  {
2645
2961
  path: ["presets"],
2646
2962
  label: "Preset roles",
@@ -2760,277 +3076,70 @@ function buildFallbacksTuiSection() {
2760
3076
  {
2761
3077
  path: ["cooldownMs"],
2762
3078
  label: "Cooldown (ms)",
2763
- descriptions: {
2764
- zh: "冷却时间(毫秒)",
2765
- en: "Cooldown (ms)"
2766
- },
2767
- group: "advanced",
2768
- kind: "number"
2769
- },
2770
- {
2771
- 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",
2783
- descriptions: {
2784
- zh: "Always 模式重试上限",
2785
- en: "Always-mode retry cap"
2786
- },
2787
- group: "advanced",
2788
- kind: "number"
2789
- },
2790
- {
2791
- path: ["revertPolicy"],
2792
- label: "Revert policy",
2793
- descriptions: {
2794
- zh: "恢复策略",
2795
- en: "Revert policy"
2796
- },
2797
- group: "advanced",
2798
- kind: "select",
2799
- options: [{
2800
- value: "cooldown-expiry",
2801
- label: "Cooldown expiry",
2802
- descriptions: {
2803
- zh: "冷却到期",
2804
- en: "Cooldown expiry"
2805
- }
2806
- }, {
2807
- value: "never",
2808
- label: "Never",
2809
- descriptions: {
2810
- zh: "从不",
2811
- en: "Never"
2812
- }
2813
- }]
2814
- }
2815
- ]
2816
- };
2817
- }
2818
- function installTuiSettingsSection(ctx, opts) {
2819
- if (!opts.serviceOwned) return;
2820
- ctx.inject(["tuiSettingsSections"], (tctx) => {
2821
- const registry = tctx.tuiSettingsSections;
2822
- if (registry === void 0) return;
2823
- try {
2824
- return registry.register(buildFallbacksTuiSection());
2825
- } catch (error) {
2826
- if (!(error instanceof Error) || !error.message.includes("already registered")) throw error;
2827
- tctx.logger("llm-fallbacks").debug("llm-fallbacks: tui settings section already registered — no section on this fiber");
2828
- return () => {};
2829
- }
2830
- });
2831
- }
2832
- //#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
3079
+ descriptions: {
3080
+ zh: "冷却时间(毫秒)",
3081
+ en: "Cooldown (ms)"
3082
+ },
3083
+ group: "advanced",
3084
+ kind: "number"
3085
+ },
3086
+ {
3087
+ path: ["maxSwitchesPerStep"],
3088
+ label: "Max switches per request walk",
3089
+ descriptions: {
3090
+ zh: "单次请求行走最大切换",
3091
+ en: "Max switches per request walk"
3092
+ },
3093
+ group: "advanced",
3094
+ kind: "number"
3095
+ },
3096
+ {
3097
+ path: ["revertPolicy"],
3098
+ label: "Revert policy",
3099
+ descriptions: {
3100
+ zh: "恢复策略",
3101
+ en: "Revert policy"
3102
+ },
3103
+ group: "advanced",
3104
+ kind: "select",
3105
+ options: [{
3106
+ value: "cooldown-expiry",
3107
+ label: "Cooldown expiry",
3108
+ descriptions: {
3109
+ zh: "冷却到期",
3110
+ en: "Cooldown expiry"
3111
+ }
3112
+ }, {
3113
+ value: "never",
3114
+ label: "Never",
3115
+ descriptions: {
3116
+ zh: "从不",
3117
+ en: "Never"
3118
+ }
3119
+ }]
2904
3120
  }
2905
- };
2906
- });
2907
- return changed ? restored : messages;
3121
+ ]
3122
+ };
2908
3123
  }
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) {
3124
+ function installTuiSettingsSection(ctx, opts) {
3125
+ if (!opts.serviceOwned) return;
3126
+ ctx.inject(["tuiSettingsSections"], (tctx) => {
3127
+ const registry = tctx.tuiSettingsSections;
3128
+ if (registry === void 0) return;
2966
3129
  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");
3130
+ return registry.register(buildFallbacksTuiSection());
3131
+ } catch (error) {
3132
+ if (!(error instanceof Error) || !error.message.includes("already registered")) throw error;
3133
+ tctx.logger("llm-fallbacks").debug("llm-fallbacks: tui settings section already registered — no section on this fiber");
3134
+ return () => {};
3013
3135
  }
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
3136
  });
3025
- return reconcile;
3026
3137
  }
3027
3138
  //#endregion
3028
3139
  //#region .build/host/index.js
3029
3140
  const name = "llm-fallbacks";
3030
3141
  const provide = ["llm-fallbacks"];
3031
3142
  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
3143
  const stateStores = new WeakMap();
3035
3144
  function stateStore(ctx) {
3036
3145
  return stateStores.get(ctx);
@@ -3048,95 +3157,38 @@ function latestByAt(map) {
3048
3157
  for (const value of map.values()) if (latest === void 0 || value.at >= latest.at) latest = value;
3049
3158
  return latest;
3050
3159
  }
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
3160
+ function agentChainView(ctx, sessionId) {
3161
+ if (sessionId === void 0) return void 0;
3162
+ const agent = ctx.get("agents")?.get(sessionId);
3163
+ if (agent === void 0) return void 0;
3164
+ const header = agent.session?.header;
3165
+ const route = agent.options;
3166
+ return {
3167
+ origin: header?.origin ?? "root",
3168
+ ...header?.agentPreset === void 0 ? {} : { agentPreset: header.agentPreset },
3169
+ ...typeof route?.provider === "string" && typeof route?.model === "string" ? { optionsRoute: {
3170
+ provider: route.provider,
3171
+ model: route.model
3172
+ } } : {},
3173
+ sessionId
3103
3174
  };
3104
3175
  }
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
3176
  function readSubagentSettings(ctx) {
3129
- const service = ctx.get("subagentModelSelection");
3130
- return service === void 0 ? void 0 : service.current();
3177
+ const settings = ctx.get("settings");
3178
+ if (settings === void 0) return void 0;
3179
+ const descriptor = settings.describe().find((d) => d.ns === SUBAGENT_POLICY_SETTINGS_NS);
3180
+ if (descriptor === void 0) return void 0;
3181
+ const policy = policySettingsFrom(descriptor.value);
3182
+ if (policy === void 0) throw new Error("subagent model-selection settings are malformed");
3183
+ return policy;
3131
3184
  }
3132
3185
  function readGuardedPolicySettings(ctx, agentId, lastKnown, warn) {
3133
3186
  try {
3134
- const service = ctx.get("subagentModelSelection");
3135
- if (service === void 0) return {
3187
+ const settings = readSubagentSettings(ctx);
3188
+ if (settings === void 0) return {
3136
3189
  ok: true,
3137
3190
  settings: lastKnown.get(agentId)
3138
3191
  };
3139
- const settings = service.current();
3140
3192
  lastKnown.set(agentId, settings);
3141
3193
  return {
3142
3194
  ok: true,
@@ -3151,22 +3203,6 @@ function readGuardedPolicySettings(ctx, agentId, lastKnown, warn) {
3151
3203
  };
3152
3204
  }
3153
3205
  }
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
3206
  function isLiveConfigRef(value) {
3171
3207
  return typeof value === "object" && value !== null && typeof value.get === "function";
3172
3208
  }
@@ -3231,12 +3267,10 @@ function apply(ctx, config = defaultFallbacksConfig) {
3231
3267
  if (settingsComposed !== void 0) return settingsComposed;
3232
3268
  return entry;
3233
3269
  };
3234
- const reconcileFallbacksAdapter = installFallbacksAdapter(ctx, () => source());
3235
3270
  validateFallbacksConfig(entry, logger);
3236
3271
  const legacyKeys = detectLegacyKeys(source());
3237
3272
  if (legacyKeys.length > 0) logger.warn("llm-fallbacks: legacy config keys detected (chains/roles.default/undeclared role refs); see docs/configuration.md migration table — %o", legacyKeys);
3238
3273
  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
3274
  ctx.inject(["settings"], (sctx) => {
3241
3275
  const settings = sctx.settings;
3242
3276
  const refreshComposed = () => {
@@ -3249,7 +3283,6 @@ function apply(ctx, config = defaultFallbacksConfig) {
3249
3283
  if (!isLiveConfigRef(config)) refreshComposed();
3250
3284
  const current = source();
3251
3285
  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
3286
  reconcileFallbacksAdapter();
3254
3287
  slotWinners.clear();
3255
3288
  });
@@ -3263,9 +3296,18 @@ function apply(ctx, config = defaultFallbacksConfig) {
3263
3296
  chainHeadStores.set(ctx, chainHeadMap);
3264
3297
  const blockedAttemptMap = new Map();
3265
3298
  blockedAttemptStores.set(ctx, blockedAttemptMap);
3299
+ const lastKnownPolicySettings = new Map();
3300
+ const policyFor = (sessionId) => {
3301
+ const settingsRead = readGuardedPolicySettings(ctx, sessionId, lastKnownPolicySettings, logger.warn);
3302
+ if (!settingsRead.ok) return { state: "unprovable" };
3303
+ return effectivePolicy(readSessionPolicyEvent(ctx.get("agents")?.get(sessionId)?.session.snapshotEvents() ?? []), settingsRead.settings);
3304
+ };
3266
3305
  const subagentSeam = installSubagentSeam(ctx, {
3267
3306
  roleIds: () => roleIds,
3268
3307
  roles: () => source().roles.list,
3308
+ source: () => source(),
3309
+ policyFor,
3310
+ chainHeads: chainHeadMap,
3269
3311
  debug: (message) => logger.debug(message)
3270
3312
  });
3271
3313
  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)");
@@ -3304,275 +3346,45 @@ function apply(ctx, config = defaultFallbacksConfig) {
3304
3346
  });
3305
3347
  const states = new FallbackStateStore();
3306
3348
  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
3349
  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
3350
+ const walkDeps = {
3351
+ readConfig: () => source(),
3352
+ getLlm: () => ctx.get("llm"),
3353
+ resolveAgentView: (sessionId) => agentChainView(ctx, sessionId),
3354
+ states,
3355
+ roleIds: () => roleIds,
3356
+ seamRecord: (sessionId) => subagentSeam.records.get(sessionId),
3357
+ policyFor,
3358
+ recordBlocked: (sessionId, route, reason) => {
3359
+ blockedAttemptMap.set(sessionId, {
3360
+ at: Date.now(),
3361
+ route,
3362
+ reason
3452
3363
  });
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
- });
3364
+ },
3365
+ slotWinners,
3366
+ log: logger
3367
+ };
3368
+ const reconcileFallbacksAdapter = installFallbacksAdapter(ctx, () => source(), (options) => walkChain(walkDeps, options));
3535
3369
  ctx.on("agent/disposed", ({ agent }) => {
3536
3370
  states.delete(agent.id);
3537
- dispatchInjected.delete(agent.id);
3538
- dispatchRoles.delete(agent.id);
3539
3371
  slotWinners.delete(agent.id);
3540
3372
  blockedAttemptMap.delete(agent.id);
3541
3373
  chainHeadMap.delete(agent.id);
3542
3374
  subagentSeam.records.delete(agent.id);
3543
3375
  lastKnownPolicySettings.delete(agent.id);
3544
3376
  });
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
- });
3560
3377
  ctx.effect(() => () => {
3561
3378
  states.clear();
3562
- dispatchInjected.clear();
3563
- dispatchRoles.clear();
3564
3379
  slotWinners.clear();
3565
3380
  blockedAttemptMap.clear();
3566
3381
  chainHeadMap.clear();
3567
- subagentSeam.records.clear();
3568
- subagentSeam.noticeEmitted.clear();
3569
- subagentSeam.dispose();
3570
3382
  lastKnownPolicySettings.clear();
3571
3383
  }, "llm-fallbacks: clear per-agent state");
3572
3384
  const fallbacksCommandController = {
3573
3385
  getSnapshot(agent) {
3574
3386
  const config = source();
3575
- const role = rememberedRole(agent.id) ?? resolveRole(agent, config.roles.rules, roleIds, logger.warn, servedRouteFor(agent, config));
3387
+ const role = subagentSeam.records.get(agent.id)?.role ?? resolveRole(agent, config.roles.rules, roleIds, logger.warn, servedRouteOf(config, agent.options, new Date()));
3576
3388
  const state = states.peek(agent.id);
3577
3389
  const recovery = config.recovery ?? "timer";
3578
3390
  if (state !== void 0 && recovery === "half-open") states.syncRecovery(state, Date.now(), recovery);
@@ -3617,9 +3429,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
3617
3429
  cooldownMs: config.cooldownMs,
3618
3430
  revertPolicy: config.revertPolicy,
3619
3431
  maxSwitchesPerStep: config.maxSwitchesPerStep,
3620
- alwaysModeRetryCap: config.alwaysModeRetryCap,
3621
- presets: config.presets ?? "bundled",
3622
- roleAutoMatch: config.roleAutoMatch ?? true
3432
+ presets: config.presets ?? "bundled"
3623
3433
  };
3624
3434
  },
3625
3435
  revertSeed: async (roleId) => {
@@ -3645,4 +3455,4 @@ function apply(ctx, config = defaultFallbacksConfig) {
3645
3455
  });
3646
3456
  }
3647
3457
  //#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 };
3458
+ export { Config, FallbacksSeedManager, INHERIT_ROLE_ID, ROLE_ID_PATTERN, ROLE_PROJECTION_KEY, SelectorError, annotateCandidates, apply, blockedAttempts, chainHeads, createCandidateFilter, defaultFallbacksConfig, detectLegacyKeys, hasWildcardEntry, name, parseSelector, presetRoles, provide, readRoleProjectionValue, resolveCandidate, resolveChain, resolveChainViews, resolveRole, selectCandidates, stateStore, validateFallbacksConfig };