dsh-model-switch 0.4.24 → 0.4.25

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/lib/compaction.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { n as sessionProviderOf, t as resolveCapabilityRoute } from "./session-route-BCQFzqRP.js";
1
2
  //#region lib/types/compaction.js
2
3
  /** Official compaction row this module supersedes. */
3
4
  const OFFICIAL_COMPACTION_ROW = "compaction-basic";
@@ -81,6 +82,100 @@ function currentCompactionRoute(ctx) {
81
82
  return compactionRouteOf(settings);
82
83
  }
83
84
  /**
85
+ * Resolve the compaction summarization route for one session id.
86
+ *
87
+ * Looks up the session from `ctx.sessions` when available, derives its current
88
+ * provider (`pending` selection preferred over `lastUsed`, then logged routes),
89
+ * and resolves the provider group's compaction pair before falling back to the
90
+ * deployment-wide compaction pair.
91
+ * @param ctx - this row's context.
92
+ * @param sessionId - the session id carried on `GenerateOptions.sessionId`.
93
+ * @returns The derived compaction route, or undefined for the engine's own route.
94
+ */
95
+ function resolveSessionCompactionRoute(ctx, sessionId) {
96
+ if (typeof sessionId !== "string" || sessionId.trim() === "") return void 0;
97
+ let settings;
98
+ try {
99
+ settings = ctx.get("modelSwitch", false)?.currentSettings();
100
+ } catch {
101
+ settings = void 0;
102
+ }
103
+ if (!settings) return void 0;
104
+ let session;
105
+ try {
106
+ session = ctx.get("sessions", false)?.get?.(sessionId);
107
+ } catch {
108
+ session = void 0;
109
+ }
110
+ const provider = sessionProviderOf(ctx, session);
111
+ return resolveCapabilityRoute(settings, provider, "compaction");
112
+ }
113
+ /**
114
+ * Wrap the real llm service so `purpose === 'compaction'` calls resolve their
115
+ * summarization route per session while keeping `compaction/summary` truthful.
116
+ *
117
+ * `summarizeWithLlm` builds a mutable `options` literal with `purpose: 'compaction'`
118
+ * and `sessionId: agent.session.id`, calls `ctx.llm.stream(options)`, and then
119
+ * returns `{ provider: options.provider, model: options.model }` to `commitCompactionBody`.
120
+ * Mutating `options.provider` and `options.model` in place before delegating to
121
+ * `target.stream(options)` ensures the durable `compaction/summary` event
122
+ * records the exact provider/model that wrote the summary. All other members
123
+ * (such as `resolveModelInfo`) pass through bound to `real`.
124
+ * @param real - the host's `ctx.llm`.
125
+ * @param resolve - per-session route resolver; undefined keeps `options` untouched.
126
+ * @param onError - optional error sink when the resolver throws.
127
+ * @returns A transparent Proxy over `real`.
128
+ */
129
+ function compactionLlm(real, resolve, onError) {
130
+ return new Proxy(real, { get(target, property, _receiver) {
131
+ if (property === "stream") return (options) => {
132
+ if (options.purpose === "compaction" && typeof options.sessionId === "string" && options.sessionId.trim() !== "") {
133
+ let route;
134
+ try {
135
+ route = resolve(options.sessionId);
136
+ } catch (cause) {
137
+ onError?.(cause);
138
+ route = void 0;
139
+ }
140
+ if (route !== void 0) {
141
+ if (Object.isFrozen(options)) return target.stream({
142
+ ...options,
143
+ provider: route.provider,
144
+ model: route.model
145
+ });
146
+ options.provider = route.provider;
147
+ options.model = route.model;
148
+ }
149
+ }
150
+ return target.stream(options);
151
+ };
152
+ const value = Reflect.get(target, property, target);
153
+ return typeof value === "function" ? value.bind(target) : value;
154
+ } });
155
+ }
156
+ /**
157
+ * Build the child context the compaction engine mounts under.
158
+ *
159
+ * When `ctx.llm` is present, shadows it with {@link compactionLlm} on an
160
+ * extended context so the child engine fiber resolves the per-session wrapper
161
+ * while registering `ctx.compaction` on the shared context tree.
162
+ * @param ctx - this row's context.
163
+ * @returns A context shadowing `llm` when available, else `ctx`.
164
+ */
165
+ function compactionScope(ctx) {
166
+ let real;
167
+ try {
168
+ real = ctx.get("llm", false);
169
+ } catch {
170
+ real = void 0;
171
+ }
172
+ if (real === void 0) return ctx;
173
+ return ctx.extend({ llm: compactionLlm(real, (sessionId) => resolveSessionCompactionRoute(ctx, sessionId), (cause) => {
174
+ ctx.logger.error("model-switch: failed to resolve per-session compaction route; keeping the engine route");
175
+ ctx.logger.error(cause);
176
+ }) });
177
+ }
178
+ /**
84
179
  * Whether the compaction capability is actually served from this context.
85
180
  *
86
181
  * Awaiting a plugin fiber settles even when the plugin's injections were never
@@ -116,7 +211,7 @@ async function mountEngine(ctx, config, label) {
116
211
  }
117
212
  let fiber;
118
213
  try {
119
- fiber = ctx.plugin(loaded.default, config);
214
+ fiber = compactionScope(ctx).plugin(loaded.default, config);
120
215
  await fiber;
121
216
  } catch (cause) {
122
217
  if (fiber) try {
@@ -235,4 +330,4 @@ async function apply(ctx) {
235
330
  await scheduleReconcile(ctx, state, false);
236
331
  }
237
332
  //#endregion
238
- export { apply, compactionRouteOf, composeCompactionConfig, engineActive, forwardedConfig, inject, name, officialCompactionEntry };
333
+ export { apply, compactionLlm, compactionRouteOf, compactionScope, composeCompactionConfig, engineActive, forwardedConfig, inject, name, officialCompactionEntry, resolveSessionCompactionRoute };
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as normalizeProviderGroups } from "./provider-group-CsZi7H29.js";
1
+ import { n as normalizeProviderGroups } from "./provider-group-CwadImhH.js";
2
2
  import { ModelSwitchAdapterRegistry } from "./adapter-registry.js";
3
3
  import { createRequire } from "node:module";
4
4
  import z from "@deepseek-ai/schemastery";
@@ -10,9 +10,6 @@ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
10
10
  import { Config as Config$1, DEEPSEEK_DEFAULT_API_VERSION, DEEPSEEK_DEFAULT_BASE_URL, DEEPSEEK_DEFAULT_MAX_TOKENS, DEEPSEEK_DEFAULT_MAX_USES, DEEPSEEK_DEFAULT_MODEL, DEEPSEEK_PROVIDER_ID, DeepSeekSearchProvider, WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE } from "@deepseek-ai/dsh-web-search-deepseek";
11
11
  import { readFileSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
- import { renderPrompt } from "@deepseek-ai/dsh-system-prompt";
14
- import { boundContextSummary, createMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
15
- import { canonicalHeader } from "@deepseek-ai/dsh-session";
16
13
  //#region lib/types/host-settings.js
17
14
  /**
18
15
  * Annotated rather than inferred: the nested entry schema's inferred type
@@ -28,7 +25,6 @@ const Config = z.object({
28
25
  searchModel: z.string().volatile(),
29
26
  imageProvider: z.string().volatile(),
30
27
  imageModel: z.string().volatile(),
31
- compactOnSwitch: z.boolean().default(true).volatile(),
32
28
  autoReviewProvider: z.string().volatile(),
33
29
  autoReviewModel: z.string().volatile(),
34
30
  compactProvider: z.string().volatile(),
@@ -72,7 +68,6 @@ function readConfig(config) {
72
68
  ...searchModel === void 0 ? {} : { searchModel },
73
69
  ...imageProvider === void 0 ? {} : { imageProvider },
74
70
  ...imageModel === void 0 ? {} : { imageModel },
75
- compactOnSwitch: config.compactOnSwitch.get(),
76
71
  ...autoReviewProvider === void 0 || autoReviewProvider === "" ? {} : { autoReviewProvider },
77
72
  ...autoReviewModel === void 0 || autoReviewModel === "" ? {} : { autoReviewModel },
78
73
  ...compactProvider === void 0 || compactProvider === "" ? {} : { compactProvider },
@@ -617,300 +612,6 @@ function allowDshRuntime(logger, pluginName, candidates) {
617
612
  return shouldMountDshRuntime(logger, pluginName, version, new Set(Object.entries(compatibility?.dshReleases ?? {}).filter(([, status]) => status === "compatible" || status === "verified").map(([release]) => release)), compatibility?.blocklist);
618
613
  }
619
614
  //#endregion
620
- //#region lib/types/switch-compact.js
621
- /** Send-time context check and optional old-model compaction before a switched request. */
622
- /** Fraction of the target window reserved for output plus estimator error. */
623
- const OUTPUT_RESERVE = .2;
624
- /** Extra tokens on top of the ratio; the meter heuristic is not exact. */
625
- const ESTIMATE_MARGIN = 64;
626
- /**
627
- * Compare two fully specified model routes.
628
- * @param left - first route, if one was resolved.
629
- * @param right - second route, if one was resolved.
630
- * @returns Whether both routes identify the same provider and model.
631
- */
632
- function sameSwitchRoute(left, right) {
633
- return left !== void 0 && right !== void 0 && left.provider === right.provider && left.model === right.model;
634
- }
635
- /**
636
- * Decide whether a changed, complete route needs send-time context protection.
637
- * @param enabled - whether the user enabled send-time protection.
638
- * @param previous - route used by the preceding request.
639
- * @param selected - route assembled for the pending request.
640
- * @returns Whether the pending request changes its provider or model.
641
- */
642
- function shouldProtectSwitch(enabled, previous, selected) {
643
- if (!enabled) return false;
644
- if (previous === void 0 || selected === void 0) return false;
645
- return !sameSwitchRoute(previous, selected);
646
- }
647
- /**
648
- * Input budget for the target window. Reserves output (maxTokens or 20%) plus a
649
- * fixed estimator margin. `undefined` means the window cannot be used.
650
- * @param contextWindow - adapter-owned combined request/response capacity.
651
- * @param maxTokens - optional generation cap from the live agent options.
652
- * @returns Tokens available to the request input, or undefined for an invalid window.
653
- */
654
- function switchInputBudget(contextWindow, maxTokens) {
655
- if (!(contextWindow > 0) || !Number.isFinite(contextWindow)) return void 0;
656
- const reserve = Math.max(Math.ceil(contextWindow * OUTPUT_RESERVE), maxTokens ?? 0) + ESTIMATE_MARGIN;
657
- return Math.max(0, Math.floor(contextWindow - reserve));
658
- }
659
- /**
660
- * Determine whether an estimated input exceeds its target route budget.
661
- * @param tokens - estimated input tokens.
662
- * @param budget - input budget for the target window.
663
- * @returns Whether the budget is unusable or the estimate is too large.
664
- */
665
- function overSwitchBudget(tokens, budget) {
666
- return !(budget > 0) || tokens > budget;
667
- }
668
- /**
669
- * Build one transcript-visible Model Switch progress notice.
670
- * @param text - localized notice text shown in the transcript.
671
- * @param summary - bounded event summary for durable session metadata.
672
- * @returns A plugin-authored user message for the session log.
673
- */
674
- function switchNotice(text, summary) {
675
- return createUserMessage({
676
- content: [{
677
- type: "text",
678
- text
679
- }],
680
- source: {
681
- kind: "model-switch",
682
- form: "notice",
683
- summary: boundContextSummary(summary)
684
- }
685
- });
686
- }
687
- /**
688
- * Extract a complete route from partial request configuration.
689
- * @param value - candidate provider and model values.
690
- * @returns A complete route, or undefined when either identifier is absent.
691
- */
692
- function routeOf(value) {
693
- if (value === void 0 || value.provider === void 0 || value.provider.length === 0 || value.model === void 0 || value.model.length === 0) return void 0;
694
- return {
695
- provider: value.provider,
696
- model: value.model
697
- };
698
- }
699
- /**
700
- * Install send-time switch protection on the host context.
701
- * @param ctx - host context that owns agent-loop, llm, and tokenMeter.
702
- * @param settings - live Model Switch settings; `compactOnSwitch !== false` is on.
703
- * @param roleOf - provider-role lookup used to exclude native Agent runtimes.
704
- */
705
- function installSwitchCompaction(ctx, settings, roleOf = () => void 0) {
706
- const assemblies = /* @__PURE__ */ new WeakMap();
707
- let stopGate;
708
- ctx.on("system-prompt/assemble", async (_assembly, context, next) => {
709
- const assembled = await next();
710
- if (context.agent !== void 0) {
711
- assemblies.set(context.agent, assembled);
712
- const previous = stopGate;
713
- let replacement;
714
- try {
715
- replacement = installGate();
716
- } catch (cause) {
717
- ctx.logger.warn("model-switch: could not refresh the switch-time compaction guard; the existing guard stays in place");
718
- ctx.logger.warn(cause);
719
- return assembled;
720
- }
721
- stopGate = replacement;
722
- previous?.();
723
- }
724
- return assembled;
725
- });
726
- function installGate() {
727
- return ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
728
- const decision = await next();
729
- if (decision.kind !== "enter" || signal.aborted) return decision;
730
- const enabled = settings().compactOnSwitch !== false;
731
- const previous = routeOf(agent.session.requestHeader()?.config);
732
- const assembly = assemblies.get(agent);
733
- const selected = routeOf(assembly === void 0 ? void 0 : {
734
- ...assembly.variables.provider === void 0 ? {} : { provider: assembly.variables.provider },
735
- ...assembly.variables.model === void 0 ? {} : { model: assembly.variables.model }
736
- });
737
- if (!shouldProtectSwitch(enabled, previous, selected) || previous === void 0 || selected === void 0) return decision;
738
- if (roleOf(previous.provider) === "agent" || roleOf(selected.provider) === "agent") return decision;
739
- return protectSwitch(ctx, agent, previous, selected, assembly, decision, messages, signal);
740
- }, { prepend: true });
741
- }
742
- }
743
- async function protectSwitch(ctx, agent, previous, selected, assembly, decision, claimed, signal) {
744
- const session = agent.session;
745
- const notice = (text, summary) => {
746
- session.append("user/message", switchNotice(text, summary), { surfaceOp: "append" });
747
- };
748
- const reject = (reason, summary, compacted) => {
749
- notice(compacted ? `无法继续本次模型切换:压缩已发生,但请求未继续(${reason})。` : `无法继续本次模型切换:${reason}`, summary);
750
- retainUserClaims(session, claimed);
751
- return { kind: "reject" };
752
- };
753
- notice("正在检查上下文…", "checking context");
754
- if (assembly === void 0) return reject("无法确定本次请求组装的目标模型。", "switch blocked: no assembled target", false);
755
- const llm = ctx.get("llm", false);
756
- if (llm?.resolveModelInfo === void 0) return reject("无法确定目标模型的上下文窗口。", "switch blocked: unknown window", false);
757
- let window;
758
- try {
759
- const value = (await llm.resolveModelInfo(selected.provider, selected.model, signal)).context?.contextWindow;
760
- window = typeof value === "number" && value > 0 ? value : void 0;
761
- } catch {
762
- window = void 0;
763
- }
764
- if (signal.aborted) return reject("已取消。", "switch blocked: cancelled", false);
765
- if (window === void 0) return reject("无法确定目标模型的上下文窗口。", "switch blocked: unknown window", false);
766
- const meter = ctx.get("tokenMeter", false);
767
- if (meter === void 0 || typeof meter.measure !== "function" || typeof meter.estimateMessage !== "function") return reject("无法估算待发上下文(缺少 token 计量)。", "switch blocked: no token meter", false);
768
- const header = targetHeader(selected, assembly);
769
- const previousHeader = session.requestHeader();
770
- let pressure;
771
- try {
772
- pressure = estimateSwitchPressure(meter, session, header, assembly, decision.messages, previousHeader);
773
- } catch (error) {
774
- return reject(`无法估算待发上下文(${error instanceof Error ? error.message : String(error)})。`, "switch blocked: estimate failed", false);
775
- }
776
- const budget = agent.options.maxTokens === void 0 ? switchInputBudget(window) : switchInputBudget(window, agent.options.maxTokens);
777
- if (budget === void 0) return reject("无法确定目标模型的上下文窗口。", "switch blocked: unknown window", false);
778
- const passed = switchNotice("上下文容量检查通过,无需压缩。", "context check passed");
779
- const completed = switchNotice("压缩完成,复查通过。", "compacted");
780
- if (!overSwitchBudget(pressure + meter.estimateMessage(passed), budget)) {
781
- session.append("user/message", passed, { surfaceOp: "append" });
782
- return decision;
783
- }
784
- let fixedPressure;
785
- try {
786
- fixedPressure = estimateFixedInput(meter, session, header, assembly, decision.messages);
787
- } catch (error) {
788
- return reject(`无法估算待发上下文(${error instanceof Error ? error.message : String(error)})。`, "switch blocked: estimate failed", false);
789
- }
790
- if (overSwitchBudget(fixedPressure + meter.estimateMessage(completed), budget)) return reject("新消息或固定请求内容已超过目标模型可用容量。", "switch blocked: fixed input oversized", false);
791
- notice("当前上下文超过目标模型可用容量,正在使用旧模型压缩…", "compacting with previous model");
792
- const compactionGeneration = session.surface.replaceGeneration;
793
- let compacted = false;
794
- try {
795
- const result = await compactWithPrevious(ctx, agent, previous, signal);
796
- compacted = result !== "none" || session.surface.replaceGeneration > compactionGeneration;
797
- if (result === "none") return reject("没有可压缩的历史。", "switch blocked: nothing to compact", compacted);
798
- } catch (error) {
799
- compacted = compacted || session.surface.replaceGeneration > compactionGeneration;
800
- if (signal.aborted) return reject("已取消。", "switch blocked: cancelled", compacted);
801
- return reject(`压缩失败(${error instanceof Error ? error.message : String(error)})。`, "switch blocked: compact failed", compacted);
802
- }
803
- if (signal.aborted) return reject("已取消。", "switch blocked: cancelled", compacted);
804
- let again;
805
- try {
806
- again = estimateSwitchPressure(meter, session, header, assembly, decision.messages);
807
- } catch (error) {
808
- return reject(`压缩后无法复查(${error instanceof Error ? error.message : String(error)})。`, "switch blocked: remeasure failed", compacted);
809
- }
810
- if (overSwitchBudget(again + meter.estimateMessage(completed), budget)) return reject("压缩后仍超过目标模型可用容量。", "switch blocked: still over budget", compacted);
811
- session.append("user/message", completed, { surfaceOp: "append" });
812
- return decision;
813
- }
814
- function targetHeader(selected, assembly) {
815
- return canonicalHeader({
816
- config: {
817
- provider: selected.provider,
818
- model: selected.model
819
- },
820
- ...assembly.tools.length > 0 ? { tools: [...assembly.tools] } : {}
821
- });
822
- }
823
- /**
824
- * Next-request input under the target route. Uses the committed surface priced
825
- * for the target (including image tokens), then adds still-pending enter
826
- * messages and the prospective system prompt while dropping already-priced
827
- * system nodes so they are not counted twice.
828
- *
829
- * The target envelope has no provider usage of its own, so the meter can only
830
- * price it with the chars-per-token heuristic. That heuristic is unconservative
831
- * for dense text (base64 payloads, minified sources, CJK). The previous
832
- * envelope holds the provider's reported usage for the same surface, so the
833
- * switch takes the larger of the two prices: the anchor keeps the check
834
- * truthful exactly where a switch makes the heuristic weakest.
835
- *
836
- * @param meter - replay token meter.
837
- * @param session - session being measured.
838
- * @param header - target request envelope.
839
- * @param assembly - prospective prompt assembly.
840
- * @param pending - messages the pre-step is about to enter.
841
- * @param previousHeader - envelope of the last request, when the session has one.
842
- * @returns Estimated next-request input tokens.
843
- */
844
- function estimateSwitchPressure(meter, session, header, assembly, pending, previousHeader) {
845
- const estimate = routePressure(meter, session, header, assembly, pending);
846
- if (previousHeader === void 0) return estimate;
847
- return Math.max(estimate, routePressure(meter, session, previousHeader, assembly, pending));
848
- }
849
- /**
850
- * Price one request envelope against the committed surface.
851
- * @param meter - replay token meter.
852
- * @param session - session being measured.
853
- * @param header - request envelope to price.
854
- * @param assembly - prospective prompt assembly.
855
- * @param pending - messages the pre-step is about to enter.
856
- * @returns Estimated input tokens for that envelope.
857
- */
858
- function routePressure(meter, session, header, assembly, pending) {
859
- const measured = meter.measure(session, header);
860
- let systemTokens = 0;
861
- for (const node of measured.nodes) if (session.eventAt(node.seq)?.type === "system/message") systemTokens += node.tokens;
862
- return measured.totalTokens - systemTokens + systemPromptTokens(meter, assembly) + pendingTokens(meter, pending);
863
- }
864
- function estimateFixedInput(meter, session, header, assembly, pending) {
865
- const measured = meter.measure(session, header);
866
- return measured.totalTokens - measured.surfaceTokens + systemPromptTokens(meter, assembly) + pendingTokens(meter, pending);
867
- }
868
- function pendingTokens(meter, messages) {
869
- let total = 0;
870
- for (const message of messages) {
871
- const tokens = meter.estimateMessage(message);
872
- if (!(tokens >= 0) || !Number.isFinite(tokens)) throw new Error("pending message token estimate is not a number");
873
- total += tokens;
874
- }
875
- return total;
876
- }
877
- function systemPromptTokens(meter, assembly) {
878
- const text = renderPrompt(assembly);
879
- if (text.length === 0) return 0;
880
- return meter.estimateMessage(createMessage({
881
- role: "system",
882
- content: [{
883
- type: "text",
884
- text
885
- }],
886
- source: { kind: "system-prompt" }
887
- }));
888
- }
889
- function retainUserClaims(session, claimed) {
890
- for (const message of claimed) {
891
- if (message.source.kind !== "user") continue;
892
- session.append("user/message", message, { surfaceOp: "append" });
893
- }
894
- }
895
- async function compactWithPrevious(ctx, agent, previous, signal) {
896
- const isolated = ctx.isolate("compaction");
897
- const Engine = (await import("@deepseek-ai/dsh-compaction-basic")).default;
898
- const fiber = isolated.plugin(Engine, {
899
- auto: false,
900
- summarizationProvider: previous.provider,
901
- summarizationModel: previous.model,
902
- compactionRetries: 0
903
- });
904
- try {
905
- await fiber;
906
- const engine = isolated.get("compaction", false);
907
- if (engine === void 0) throw new Error("private compaction engine is unavailable");
908
- return await engine.compactIfNeeded(agent, "context-overflow", signal) === null ? "none" : "compacted";
909
- } finally {
910
- await fiber.dispose();
911
- }
912
- }
913
- //#endregion
914
615
  //#region lib/types/host-runtime.js
915
616
  /** Host owner for Model Switch settings. */
916
617
  var ModelSwitchRuntime = class extends Service {
@@ -933,7 +634,6 @@ var ModelSwitchRuntime = class extends Service {
933
634
  this.source = () => readConfig(entry);
934
635
  ctx.inject(["settings"], (scope) => scope.effect(() => scope.settings.configure({ auto: false }, ctx.fiber), "Model Switch: disable automatic settings form"));
935
636
  if (!allowDshRuntime(ctx.logger, "dsh-model-switch", ["@deepseek-ai/dsh-agent"])) return;
936
- installSwitchCompaction(ctx, () => this.source(), (provider) => this.adapters.get(provider)?.role);
937
637
  installDeepSeekSearchAdapter(ctx);
938
638
  installModelSwitchSearchProvider(ctx, this);
939
639
  installCapabilitiesRpc(ctx, this.adapters, () => this.capabilities);
@@ -1,20 +1,14 @@
1
1
  //#region lib/types/provider-group.js
2
2
  /**
3
- * Provider configuration groups: per-provider presets for the session-naming
4
- * and subagent routes.
3
+ * Provider configuration groups: per-provider presets for the compaction,
4
+ * auto-review, session-naming, and subagent routes.
5
5
  *
6
6
  * A group is a DERIVED policy, not a write: `src/session-route.ts` resolves the
7
7
  * routes one session should use from its own provider plus this stored table, so
8
8
  * switching a session's provider never rewrites deployment-wide settings and two
9
9
  * sessions on different providers cannot overwrite each other. The field names
10
10
  * below are the flat settings field names a group pins, which keeps the stored
11
- * shape identical to the earlier write-through design.
12
- *
13
- * Legacy entries may still carry `compactProvider`/`compactModel`/
14
- * `autoReviewProvider`/`autoReviewModel`: normalization preserves them so an
15
- * older stored document is never silently rewritten into a different meaning,
16
- * while the consumption side ignores them (a group save from the current UI
17
- * writes '' for every unsupported field, which normalization drops).
11
+ * shape identical across releases.
18
12
  *
19
13
  * All functions here are pure and never throw on malformed storage.
20
14
  */
@@ -1,4 +1,4 @@
1
- import { t as groupOf } from "./provider-group-CsZi7H29.js";
1
+ import { t as groupOf } from "./provider-group-CwadImhH.js";
2
2
  //#region lib/types/session-route.js
3
3
  /**
4
4
  * Per-session capability route derivation.
@@ -1,4 +1,4 @@
1
- import { n as sessionProviderOf, t as resolveCapabilityRoute } from "./session-route-h9SAqajc.js";
1
+ import { n as sessionProviderOf, t as resolveCapabilityRoute } from "./session-route-BCQFzqRP.js";
2
2
  //#region lib/types/session-title.js
3
3
  /**
4
4
  * Model Switch-owned session-naming row.
@@ -1,4 +1,4 @@
1
- import { r as sessionSubagentRouteOf } from "./session-route-h9SAqajc.js";
1
+ import { r as sessionSubagentRouteOf } from "./session-route-BCQFzqRP.js";
2
2
  import { ReasoningEffortId } from "@deepseek-ai/dsh-llm";
3
3
  import OfficialSubagentRuntime from "@deepseek-ai/dsh-subagent";
4
4
  //#region lib/types/subagent-runtime.js
@@ -41,7 +41,7 @@ export interface ModelSwitchImageAdapter {
41
41
  }
42
42
  export interface ModelSwitchProviderAdapters {
43
43
  readonly provider: string;
44
- /** Native Agent providers own their context and are excluded from DSH switch compaction. */
44
+ /** Optional runtime role declared by the provider integration. */
45
45
  readonly role?: 'llm' | 'agent';
46
46
  readonly search?: ModelSwitchSearchAdapter;
47
47
  readonly image?: ModelSwitchImageAdapter;
@@ -5,17 +5,20 @@
5
5
  * reviewer to the session's own request route (`header.config`). This row runs
6
6
  * the official `apply` under a scoped child context whose `llm` service is
7
7
  * shadowed by a wrapper that rewrites only `stream()`'s provider/model — and
8
- * only while the profile has a complete reviewer route configured. With no
9
- * route configured the wrapper passes options through by identity, so the
10
- * reviewer follows the session model exactly.
8
+ * only when the reviewing session's derived route (its provider group's
9
+ * reviewer pair, else the deployment-wide reviewer card) pins one. With no
10
+ * route pinned the wrapper passes options through by identity, so the reviewer
11
+ * follows the session model exactly.
11
12
  *
12
- * The reviewer route is deliberately DEPLOYMENT-WIDE. The official gate builds
13
- * its request with no session id and no purpose and closes over the single
14
- * context it was applied with, so this plugin has no public way to tell which
15
- * session a review serves; guessing one would attribute a review to the wrong
16
- * session and make the recorded reviewer route false. The unsupported
17
- * per-session reviewer route is recorded in `docs/seam-gap.md` with the public
18
- * seam it would need.
13
+ * Although the official gate omits `sessionId` and `purpose` from its frozen
14
+ * `GenerateOptions`, its `tools/pre-execute` waterfall listener receives
15
+ * `exec.agent` (`exec.agent.session`). After running `official.apply(scope)`,
16
+ * this module registers a `{ prepend: true }` `tools/pre-execute` listener on
17
+ * the same scope — which Cordis places ahead of the official gate's listener in
18
+ * the waterfall — and binds `exec.agent?.session` in an `AsyncLocalStorage`
19
+ * scope around `next()`. Every `scope.llm.stream()` call from `classifyRisk`
20
+ * therefore resolves the exact session being reviewed, with zero cross-session
21
+ * interference when multiple sessions review tool calls concurrently.
19
22
  *
20
23
  * The official gate stays an opt-in capability: the row mounts it only when
21
24
  * the profile actually started with the experimental-auto-review bundle.
@@ -24,8 +27,10 @@
24
27
  *
25
28
  * @module dsh-model-switch/auto-review
26
29
  */
30
+ import { AsyncLocalStorage } from 'node:async_hooks';
27
31
  import type { Context } from '@deepseek-ai/cordis';
28
32
  import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
33
+ import type { Session } from '@deepseek-ai/dsh-session';
29
34
  /** Cordis plugin name used by loader diagnostics. */
30
35
  export declare const name = "model-switch-auto-review";
31
36
  /** The official gate's host services plus Model Switch's settings owner. */
@@ -60,12 +65,34 @@ export declare function autoReviewRouteOf(settings: {
60
65
  * @returns A structurally compatible llm face for the scoped context.
61
66
  */
62
67
  export declare function reviewLlm(real: ReviewLlm, route: () => AutoReviewRoute | undefined): ReviewLlm;
68
+ /**
69
+ * Resolve the reviewer route for one session (or deployment-wide when no session is bound).
70
+ *
71
+ * @param ctx - host context whose modelSwitch settings and sessionProjections derive the route.
72
+ * @param session - the session whose tool call is being reviewed, when known.
73
+ * @returns The derived reviewer route, or undefined to follow the session model.
74
+ */
75
+ export declare function resolveSessionAutoReviewRoute(ctx: Context, session?: Session): AutoReviewRoute | undefined;
76
+ /**
77
+ * Install the outer `tools/pre-execute` correlation listener ahead of the official gate.
78
+ *
79
+ * Because Cordis `on(..., { prepend: true })` unshifts onto the event hook list,
80
+ * registering this listener AFTER `official.apply(scope)` places it at index 0
81
+ * — directly ahead of the official gate's own prepended listener — so
82
+ * `activeSession.run(exec.agent?.session, next)` wraps the entire official
83
+ * review execution.
84
+ *
85
+ * @param scope - the review scope context where the gate is mounted.
86
+ * @param activeSession - AsyncLocalStorage holding the session under review.
87
+ */
88
+ export declare function bindAutoReviewSessionCorrelation(scope: Context, activeSession: AsyncLocalStorage<Session | undefined>): void;
63
89
  /**
64
90
  * Build the scoped context the official gate runs under.
65
91
  * @param ctx - host context whose modelSwitch settings pin the reviewer route.
92
+ * @param activeSession - optional AsyncLocalStorage correlating each `tools/pre-execute` call to its session.
66
93
  * @returns A child context shadowing only the llm service.
67
94
  */
68
- export declare function reviewScope(ctx: Context): Context;
95
+ export declare function reviewScope(ctx: Context, activeSession?: AsyncLocalStorage<Session | undefined>): Context;
69
96
  /**
70
97
  * Mount the official gate under the reviewer-route scope.
71
98
  *
@@ -26,7 +26,6 @@ export interface ModelSwitchSettingsFace {
26
26
  */
27
27
  saveSubagent: (ops: readonly ConfigViewOp<SubagentSettingsView>[]) => Promise<void>;
28
28
  saveCapability: (route: 'search' | 'image' | 'autoReview' | 'compaction' | 'title', ops: readonly ConfigViewOp<CapabilityRouteView>[]) => Promise<void>;
29
- setCompactOnSwitch: (value: boolean) => Promise<void>;
30
29
  /** Replace or remove (undefined) one provider's saved capability group. */
31
30
  saveProviderGroup: (provider: string, group: ProviderGroupConfig | undefined) => Promise<void>;
32
31
  loadCatalog: () => Promise<readonly ModelProviderGroup[]>;
@@ -4,12 +4,9 @@ export declare const zh: {
4
4
  readonly subtitle: "为子代理与能力路由设置固定模型;当前会话模型用输入框旁的选择器直接切换。";
5
5
  readonly conversationRoutes: "对话路由";
6
6
  readonly capabilityRoutes: "能力路由";
7
- readonly sendProtection: "发送保护";
8
7
  readonly settingsSynced: "设置已同步";
9
8
  readonly remoteSettingsUnavailable: "官方 DSH 在非本机浏览器禁用配置读取与写入;请在 Host 的 127.0.0.1 页面打开设置。";
10
9
  readonly cancel: "取消";
11
- readonly compactOnSwitch: "切换时压缩";
12
- readonly compactOnSwitchHelp: "发送且换了模型时,先按目标窗口检查上下文;超了就用切换前的模型压一次历史,再继续请求。只选模型、不发送,不会压缩。官方自动压缩和 /compact 不受影响。压缩会产生额外模型用量,摘要可能省略细节。";
13
10
  readonly provider: "提供商";
14
11
  readonly model: "模型";
15
12
  readonly providerDefault: "使用提供商默认值";
@@ -46,8 +43,8 @@ export declare const zh: {
46
43
  readonly providerGroups: "供应商配置组";
47
44
  readonly providerGroupsCard: "按供应商预设路由";
48
45
  readonly providerGroupsSummary: "使用该供应商的会话自动采用对应组";
49
- readonly providerGroupsHelp: "为会话命名与子代理按供应商分别保存路由。";
50
- readonly providerGroupsBodyHelp: "每个供应商保存一组会话命名与子代理路由。会话自身的供应商与该组一致时,该会话的命名与子代理按组解析,否则回落到上方卡片里的全局设置。切换某个会话的模型不会写入任何全局设置,因此不同供应商的多个会话互不影响。压缩与自动审查仍是全局卡片:压缩事件要如实记录实际写入摘要的模型,审查的拦截点没有会话身份。";
46
+ readonly providerGroupsHelp: "为压缩、自动审查、会话命名与子代理按供应商分别保存路由。";
47
+ readonly providerGroupsBodyHelp: "每个供应商保存一组压缩、自动审查、会话命名与子代理路由。会话自身的供应商与该组一致时,这四项能力按组实时解析,未固定项回落到上方卡片里的全局设置。切换某个会话的模型不会写入任何全局设置,因此不同供应商的多个会话并行时互不影响,会话中切换供应商后下一次调用也会立即跟随新供应商的配置组。";
51
48
  readonly providerGroupPick: "选择供应商以编辑其配置组。";
52
49
  readonly providerGroupDelete: "删除该组";
53
50
  readonly saveTimeout: "保存超时,请重试";
@@ -10,7 +10,6 @@ export interface ModelSwitchSettingsView {
10
10
  searchModel?: string;
11
11
  imageProvider?: string;
12
12
  imageModel?: string;
13
- compactOnSwitch?: boolean;
14
13
  autoReviewProvider?: string;
15
14
  autoReviewModel?: string;
16
15
  compactProvider?: string;