@lienze/dsh-subagent-model-policy 0.0.0-stage → 0.3.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/locale/en.json ADDED
@@ -0,0 +1 @@
1
+ {"meta":{"title":"Subagent Model Policy","description":"Session-scoped model guidance, decision context, local child admission checks and execution evidence. Other sessions remain unchanged by default."}}
package/locale/zh.json ADDED
@@ -0,0 +1 @@
1
+ {"meta":{"title":"子智能体模型选择策略","description":"会话级模型选择、场景说明、决策上下文与本地子智能体准入校验及执行回读。默认不干预其他会话。"}}
package/management.js ADDED
@@ -0,0 +1,673 @@
1
+ // Session-scoped strategy library, model pool, shared limits and new-session
2
+ // default. Pure rules only: no Host access, no storage, no timers.
3
+ //
4
+ // Storage compatibility: a `policies` record may still be the v1 shape
5
+ // {revision, policy}. Such a record is read as a *legacy* library view and is
6
+ // never rewritten by a read. The first explicit library mutation materializes
7
+ // it, preserving the legacy strategy (including `mode=inherit`) instead of
8
+ // silently converting it to fixed/auto.
9
+ import { defaults, fail, routeKey, sameRoute } from './policy.js';
10
+
11
+ export const LIBRARY_VERSION = 1;
12
+ export const MAX_MODELS = 24;
13
+ export const MAX_NAME = 60;
14
+ export const MAX_TEXT = 2000;
15
+ export const MAX_PURPOSE = 500;
16
+ export const MAX_NOTE = 500;
17
+ export const PLUGIN_MAX_ACTIVE = 32;
18
+ export const STRATEGY_TYPES = Object.freeze(['fixed', 'auto']);
19
+ export const LEGACY_STRATEGY_ID = 'legacy';
20
+ export const LEGACY_INHERIT_ID = 'legacy-inherit';
21
+
22
+ const clone = (value) => structuredClone(value);
23
+ const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
24
+ const routeOf = (value) => ({ provider: value.provider, model: value.model });
25
+ export const routeKeyOf = (value) => routeKey(value);
26
+
27
+ function textValue(value, max, field, opts = {}) {
28
+ const fallback = opts.fallback ?? '';
29
+ if (value === undefined && opts.optional) return fallback;
30
+ if (typeof value !== 'string') fail(`${field} 必须是字符串`);
31
+ if (value.length > max) fail(`${field} 过长(最多 ${max} 字符)`);
32
+ const trimmed = opts.trim === false ? value : value.trim();
33
+ if (!opts.allowEmpty && !trimmed) fail(`${field} 不能为空`);
34
+ return trimmed;
35
+ }
36
+
37
+ function intValue(value, min, max, field) {
38
+ if (!Number.isSafeInteger(value) || value < min || value > max) fail(`${field} 必须是 ${min}–${max} 的整数`);
39
+ return value;
40
+ }
41
+
42
+ function requestObject(raw, allowed, field) {
43
+ if (!isObject(raw)) fail(`${field} 必须是对象`);
44
+ for (const key of Object.keys(raw)) if (!allowed.includes(key)) fail(`${field} 存在未知字段:${key}`);
45
+ return raw;
46
+ }
47
+
48
+ /** Validate a route array for a request; duplicate routes are rejected. */
49
+ export function readRoutes(raw, field = '模型', max = MAX_MODELS) {
50
+ if (!Array.isArray(raw)) fail(`${field}必须是数组`);
51
+ if (raw.length > max) fail(`${field}最多 ${max} 项`);
52
+ const seen = new Set();
53
+ return raw.map((item) => {
54
+ requestObject(item, ['provider', 'model'], field);
55
+ const route = {
56
+ provider: textValue(item.provider, 200, `${field} provider`),
57
+ model: textValue(item.model, 200, `${field} model`),
58
+ };
59
+ const key = routeKey(route);
60
+ if (seen.has(key)) fail(`${field}存在重复路由:${route.provider}/${route.model}`);
61
+ seen.add(key);
62
+ return route;
63
+ });
64
+ }
65
+
66
+ /** Validate one pool entry as sent by the client. */
67
+ export function readPoolEntries(raw, field = '模型池') {
68
+ if (!Array.isArray(raw)) fail(`${field}必须是数组`);
69
+ if (raw.length > MAX_MODELS) fail(`${field}最多 ${MAX_MODELS} 项`);
70
+ const seen = new Set();
71
+ return raw.map((item) => {
72
+ requestObject(item, ['provider', 'model', 'purpose', 'note'], field);
73
+ const entry = {
74
+ provider: textValue(item.provider, 200, `${field} provider`),
75
+ model: textValue(item.model, 200, `${field} model`),
76
+ purpose: textValue(item.purpose, MAX_PURPOSE, '简短用途', { optional: true, allowEmpty: true }),
77
+ note: textValue(item.note, MAX_NOTE, '通用使用说明', { optional: true, allowEmpty: true }),
78
+ };
79
+ const key = routeKey(entry);
80
+ if (seen.has(key)) fail(`${field}存在重复模型:${entry.provider}/${entry.model}`);
81
+ seen.add(key);
82
+ return entry;
83
+ });
84
+ }
85
+
86
+ /** Validate a strategy form. Type is restricted to the two page types. */
87
+ export function readStrategyDraft(raw, field = '策略') {
88
+ requestObject(raw, ['id', 'name', 'type', 'models', 'fixedProvider', 'fixedModel', 'strategyText'], field);
89
+ const draft = {
90
+ id: raw.id === undefined ? undefined : textValue(raw.id, 120, '策略 id'),
91
+ name: textValue(raw.name, MAX_NAME, '策略名称'),
92
+ type: raw.type,
93
+ models: readRoutes(raw.models, '策略引用模型'),
94
+ fixedProvider: textValue(raw.fixedProvider, 200, '固定提供方', { optional: true, allowEmpty: true }),
95
+ fixedModel: textValue(raw.fixedModel, 200, '固定模型', { optional: true, allowEmpty: true }),
96
+ strategyText: textValue(raw.strategyText, MAX_TEXT, '选择说明', { optional: true, allowEmpty: true }),
97
+ };
98
+ if (!STRATEGY_TYPES.includes(draft.type)) fail('策略类型必须是固定模型或 Agent 自选');
99
+ if (!draft.models.length) fail('至少选择一个模型');
100
+ if (draft.type === 'fixed') {
101
+ if (draft.models.length !== 1) fail('固定模型策略只能选择一个模型');
102
+ const only = draft.models[0];
103
+ if ((draft.fixedProvider && draft.fixedProvider !== only.provider) || (draft.fixedModel && draft.fixedModel !== only.model)) {
104
+ fail('固定模型必须来自策略中选择的模型');
105
+ }
106
+ draft.fixedProvider = only.provider;
107
+ draft.fixedModel = only.model;
108
+ draft.strategyText = '';
109
+ } else {
110
+ if (draft.fixedProvider || draft.fixedModel) fail('Agent 自选策略不应包含固定模型');
111
+ draft.fixedProvider = '';
112
+ draft.fixedModel = '';
113
+ }
114
+ return draft;
115
+ }
116
+
117
+ /** Validate a strategy persisted in a library record (never trims away data). */
118
+ function readStoredStrategy(raw, field = '已保存策略') {
119
+ if (!isObject(raw)) fail(`${field}必须是对象`);
120
+ const legacy = raw.legacy === true;
121
+ const strategy = {
122
+ id: textValue(raw.id, 120, `${field} id`),
123
+ name: textValue(raw.name, MAX_NAME, `${field} 名称`),
124
+ type: raw.type,
125
+ models: readRoutes(raw.models, `${field} 引用模型`),
126
+ fixedProvider: typeof raw.fixedProvider === 'string' ? raw.fixedProvider : '',
127
+ fixedModel: typeof raw.fixedModel === 'string' ? raw.fixedModel : '',
128
+ strategyText: typeof raw.strategyText === 'string' ? raw.strategyText : '',
129
+ version: intValue(raw.version, 1, Number.MAX_SAFE_INTEGER, `${field} 版本`),
130
+ };
131
+ if (!STRATEGY_TYPES.includes(strategy.type) && strategy.type !== 'inherit') fail(`${field} 类型无效`);
132
+ if (strategy.type === 'inherit') {
133
+ if (!legacy) fail(`${field} 的跟随类型仅用于旧版只读策略`);
134
+ strategy.legacy = true;
135
+ } else if (strategy.type === 'fixed') {
136
+ if (!strategy.models.length) fail(`${field} 固定模型至少需要一个路由`);
137
+ // A v1 legacy fixed policy may keep several candidates and its fixed target
138
+ // may be any one of them. New strategies stay strictly single-model.
139
+ const target = { provider: strategy.fixedProvider, model: strategy.fixedModel };
140
+ if (!strategy.models.some((route) => sameRoute(route, target))) fail(`${field} 固定路由不一致`);
141
+ if (!legacy && strategy.models.length !== 1) fail(`${field} 固定模型必须只有一个路由`);
142
+ } else if (!strategy.models.length) {
143
+ fail(`${field} 至少需要一个引用模型`);
144
+ }
145
+ if (legacy) strategy.legacy = true;
146
+ return strategy;
147
+ }
148
+
149
+ function readModelsInfo(raw, field = '模型说明') {
150
+ if (raw === undefined) return [];
151
+ if (!Array.isArray(raw)) fail(`${field}必须是数组`);
152
+ return raw.map((item) => {
153
+ if (!isObject(item)) fail(`${field} 必须是对象`);
154
+ return {
155
+ provider: textValue(item.provider, 200, `${field} provider`),
156
+ model: textValue(item.model, 200, `${field} model`),
157
+ name: typeof item.name === 'string' ? item.name : String(item.model),
158
+ purpose: typeof item.purpose === 'string' ? item.purpose : '',
159
+ note: typeof item.note === 'string' ? item.note : '',
160
+ };
161
+ });
162
+ }
163
+
164
+ function readLimits(raw, field = '会话限制') {
165
+ if (raw === null || raw === undefined) return null;
166
+ if (!isObject(raw)) fail(`${field} 必须是对象`);
167
+ return {
168
+ maxDepth: intValue(raw.maxDepth, 0, Math.min(32, PLUGIN_MAX_ACTIVE), `${field} 深度`),
169
+ maxActive: intValue(raw.maxActive, 1, PLUGIN_MAX_ACTIVE, `${field} 并发子智能体数量`),
170
+ };
171
+ }
172
+
173
+ function readSnapshot(raw, field = '已启用内容') {
174
+ if (!isObject(raw)) fail(`${field} 必须是对象`);
175
+ const legacy = raw.legacy === true;
176
+ const snapshot = {
177
+ version: intValue(raw.version, 1, Number.MAX_SAFE_INTEGER, `${field} 版本`),
178
+ strategyId: typeof raw.strategyId === 'string' ? raw.strategyId : '',
179
+ name: textValue(raw.name, MAX_NAME, `${field} 名称`),
180
+ type: raw.type,
181
+ models: readRoutes(raw.models, `${field} 模型`),
182
+ fixedProvider: typeof raw.fixedProvider === 'string' ? raw.fixedProvider : '',
183
+ fixedModel: typeof raw.fixedModel === 'string' ? raw.fixedModel : '',
184
+ strategyText: typeof raw.strategyText === 'string' ? raw.strategyText : '',
185
+ modelsInfo: readModelsInfo(raw.modelsInfo),
186
+ capturedAt: Number.isSafeInteger(raw.capturedAt) ? raw.capturedAt : null,
187
+ };
188
+ if (!STRATEGY_TYPES.includes(snapshot.type) && snapshot.type !== 'inherit') fail(`${field} 类型无效`);
189
+ if (snapshot.type === 'inherit' && !legacy) fail(`${field} 的跟随类型只能来自旧版只读快照`);
190
+ if (snapshot.type === 'fixed') {
191
+ if (!snapshot.models.length) fail(`${field} 固定模型至少需要一个路由`);
192
+ // Legacy snapshots keep every v1 candidate and may target any of them.
193
+ if (!snapshot.models.some((route) => sameRoute(route, { provider: snapshot.fixedProvider, model: snapshot.fixedModel }))) fail(`${field} 固定路由不一致`);
194
+ if (!legacy && snapshot.models.length !== 1) fail(`${field} 固定模型必须只有一个路由`);
195
+ }
196
+ if (legacy) snapshot.legacy = true;
197
+ return snapshot;
198
+ }
199
+
200
+ /** Read one persisted library. Unknown fields are tolerated; known fields are validated. */
201
+ export function readLibrary(raw) {
202
+ if (!isObject(raw)) fail('策略库数据无效');
203
+ if (raw.version !== LIBRARY_VERSION) fail(`策略库版本不受支持:${String(raw.version)}`);
204
+ if (!Array.isArray(raw.strategies)) fail('策略库 strategies 无效');
205
+ if (raw.strategies.length > 50) fail('策略库策略过多');
206
+ const strategies = raw.strategies.map((strategy) => readStoredStrategy(strategy));
207
+ const ids = new Set();
208
+ for (const strategy of strategies) {
209
+ if (ids.has(strategy.id)) fail(`策略库存在重复 id:${strategy.id}`);
210
+ ids.add(strategy.id);
211
+ }
212
+ const pool = readPoolEntriesLenient(raw.pool);
213
+ let active = null;
214
+ if (raw.active !== null && raw.active !== undefined) {
215
+ if (!isObject(raw.active)) fail('已启用策略记录无效');
216
+ active = {
217
+ strategyId: textValue(raw.active.strategyId, 120, '已启用策略 id'),
218
+ appliedAt: Number.isSafeInteger(raw.active.appliedAt) ? raw.active.appliedAt : null,
219
+ snapshot: readSnapshot(raw.active.snapshot),
220
+ };
221
+ }
222
+ let initialized = null;
223
+ if (isObject(raw.initialized)) {
224
+ initialized = {
225
+ at: Number.isSafeInteger(raw.initialized.at) ? raw.initialized.at : null,
226
+ defaultRevision: Number.isSafeInteger(raw.initialized.defaultRevision) ? raw.initialized.defaultRevision : null,
227
+ source: raw.initialized.source === 'default' ? 'default' : 'none',
228
+ };
229
+ }
230
+ const legacyPolicy = isObject(raw.legacyPolicy) ? clone(raw.legacyPolicy) : undefined;
231
+ return { version: LIBRARY_VERSION, strategies, pool, limits: readLimits(raw.limits), active, initialized, legacyPolicy };
232
+ }
233
+
234
+ function readPoolEntriesLenient(raw) {
235
+ if (!Array.isArray(raw)) fail('模型池数据无效');
236
+ if (raw.length > MAX_MODELS) fail('模型池条目过多');
237
+ const seen = new Set();
238
+ return raw.map((item) => {
239
+ if (!isObject(item)) fail('模型池条目无效');
240
+ const entry = {
241
+ provider: textValue(item.provider, 200, '模型池 provider'),
242
+ model: textValue(item.model, 200, '模型池 model'),
243
+ purpose: typeof item.purpose === 'string' ? item.purpose.slice(0, MAX_PURPOSE) : '',
244
+ note: typeof item.note === 'string' ? item.note.slice(0, MAX_NOTE) : '',
245
+ };
246
+ if (seen.has(routeKey(entry))) fail('模型池存在重复模型');
247
+ seen.add(routeKey(entry));
248
+ return entry;
249
+ });
250
+ }
251
+
252
+ /** Read the global new-session default record. */
253
+ export function readDefault(raw) {
254
+ if (!isObject(raw)) fail('新会话默认数据无效');
255
+ if (raw.default === null || raw.default === undefined) return null;
256
+ if (!isObject(raw.default)) fail('新会话默认内容无效');
257
+ const value = raw.default;
258
+ const snapshot = readSnapshot({ ...value, strategyId: value.sourceId || '' }, '新会话默认');
259
+ const limits = readLimits(value.limits, '默认会话限制');
260
+ const sourceId = typeof value.sourceId === 'string' ? value.sourceId : '';
261
+ const sourceName = typeof value.sourceName === 'string' ? value.sourceName : '';
262
+ if (snapshot.type === 'inherit') fail('新会话默认不能来自跟随类型');
263
+ return { ...snapshot, sourceId, sourceName, sourceVersion: intValue(value.sourceVersion ?? snapshot.version, 1, Number.MAX_SAFE_INTEGER, '默认来源版本'), limits };
264
+ }
265
+
266
+ /** Legacy policy -> read-only library view (never persisted by a read). */
267
+ export function legacyLibrary(policy) {
268
+ const pool = (policy.models || []).map((model) => ({
269
+ provider: model.provider,
270
+ model: model.model,
271
+ purpose: model.scenarios || '',
272
+ note: model.limitations || '',
273
+ }));
274
+ const type = policy.mode === 'inherit' ? 'inherit' : policy.mode;
275
+ const strategy = {
276
+ id: type === 'inherit' ? LEGACY_INHERIT_ID : LEGACY_STRATEGY_ID,
277
+ name: type === 'inherit' ? '旧版跟随策略' : '旧版会话策略',
278
+ type,
279
+ models: pool.map(routeOf),
280
+ fixedProvider: type === 'fixed' ? policy.fixedProvider : '',
281
+ fixedModel: type === 'fixed' ? policy.fixedModel : '',
282
+ strategyText: policy.strategyText || '',
283
+ version: 1,
284
+ legacy: true,
285
+ };
286
+ const limits = { maxDepth: policy.maxDepth, maxActive: policy.maxActive };
287
+ const active = policy.enabled
288
+ ? { strategyId: strategy.id, appliedAt: null, snapshot: snapshotForStrategy(strategy, pool, { now: null, limits, strategyId: strategy.id }) }
289
+ : null;
290
+ return { version: LIBRARY_VERSION, strategies: [strategy], pool, limits, active, initialized: null };
291
+ }
292
+
293
+ /** Materialize a legacy policy into an explicit library (lossless, not a type conversion). */
294
+ export function materializeLegacy(policy) {
295
+ const library = legacyLibrary(policy);
296
+ // Keep the exact legacy policy so pooled/limits-only edits do not change the
297
+ // effective preset, model notes or cost/speed/source metadata until the user
298
+ // explicitly saves the enabled strategy or enables another one.
299
+ library.legacyPolicy = clone(policy);
300
+ return library;
301
+ }
302
+
303
+ /** Empty explicit library for a brand-new root without a default. */
304
+ export function emptyLibrary({ now } = {}) {
305
+ return {
306
+ version: LIBRARY_VERSION,
307
+ strategies: [],
308
+ pool: [],
309
+ limits: null,
310
+ active: null,
311
+ initialized: { at: Number.isSafeInteger(now) ? now : Date.now(), defaultRevision: null, source: 'none' },
312
+ };
313
+ }
314
+
315
+ /** Build the effective runtime policy from an explicit library and current saved limits. */
316
+ export function buildPolicy(library, hostDepth) {
317
+ const limits = library.limits;
318
+ const active = library.active;
319
+ const maxDepth = limits ? limits.maxDepth : hostDepth;
320
+ const maxActive = limits ? limits.maxActive : PLUGIN_MAX_ACTIVE;
321
+ const unset = !limits;
322
+ if (!active) {
323
+ return {
324
+ ...defaults(hostDepth, []),
325
+ enabled: false,
326
+ maxDepth,
327
+ maxActive,
328
+ limitsUnset: unset,
329
+ };
330
+ }
331
+ if (library.legacyPolicy && (active.strategyId === LEGACY_STRATEGY_ID || active.strategyId === LEGACY_INHERIT_ID)) {
332
+ const legacy = clone(library.legacyPolicy);
333
+ // pool.note/save/sync refresh only active.snapshot.modelsInfo, while the
334
+ // legacy copy still drives the effective policy. Fold those descriptions in
335
+ // by route so the next preview/injection sees them; preset, strategyText,
336
+ // the fixed target and cost/speed/source stay exactly as saved.
337
+ const infos = Array.isArray(active.snapshot?.modelsInfo) ? active.snapshot.modelsInfo : [];
338
+ if (infos.length) {
339
+ const byKey = new Map(infos.map((info) => [routeKey(info), info]));
340
+ legacy.models = (legacy.models || []).map((model) => {
341
+ const info = byKey.get(routeKey(model));
342
+ return info
343
+ ? { ...model, scenarios: info.purpose || '', limitations: info.note || '' }
344
+ : model;
345
+ });
346
+ }
347
+ legacy.maxDepth = maxDepth;
348
+ legacy.maxActive = maxActive;
349
+ legacy.limitsUnset = unset;
350
+ return legacy;
351
+ }
352
+ const snapshot = active.snapshot;
353
+ const infos = Array.isArray(snapshot.modelsInfo) && snapshot.modelsInfo.length
354
+ ? snapshot.modelsInfo
355
+ : (snapshot.models || []).map((route) => ({ ...route, name: route.model, purpose: '', note: '' }));
356
+ const models = infos.map((info) => ({
357
+ provider: info.provider,
358
+ model: info.model,
359
+ scenarios: info.purpose || '',
360
+ limitations: info.note || '',
361
+ cost: 'unknown',
362
+ speed: 'unknown',
363
+ source: 'unverified',
364
+ }));
365
+ return {
366
+ enabled: true,
367
+ mode: snapshot.type === 'inherit' ? 'inherit' : snapshot.type,
368
+ preset: 'custom',
369
+ strategyText: snapshot.strategyText || '',
370
+ maxDepth,
371
+ maxActive,
372
+ limitsUnset: unset,
373
+ fixedProvider: snapshot.fixedProvider || '',
374
+ fixedModel: snapshot.fixedModel || '',
375
+ models,
376
+ };
377
+ }
378
+
379
+ /** Build a snapshot from a strategy + pool descriptions. */
380
+ export function snapshotForStrategy(strategy, pool, { now = Date.now(), limits = null, strategyId = strategy.id } = {}) {
381
+ const byKey = new Map(pool.map((entry) => [routeKey(entry), entry]));
382
+ const models = strategy.models.map(routeOf);
383
+ const modelsInfo = models.map((route) => {
384
+ const entry = byKey.get(routeKey(route));
385
+ return {
386
+ provider: route.provider,
387
+ model: route.model,
388
+ name: entry?.name || route.model,
389
+ purpose: entry?.purpose || '',
390
+ note: entry?.note || '',
391
+ };
392
+ });
393
+ return {
394
+ version: strategy.version,
395
+ strategyId,
396
+ name: strategy.name,
397
+ type: strategy.type,
398
+ models,
399
+ fixedProvider: strategy.type === 'fixed' ? strategy.fixedProvider : '',
400
+ fixedModel: strategy.type === 'fixed' ? strategy.fixedModel : '',
401
+ // Legacy v1 policies can carry a non-empty fixed-mode strategyText; never
402
+ // drop it from the view or the effective snapshot.
403
+ strategyText: strategy.strategyText || '',
404
+ modelsInfo,
405
+ capturedAt: now,
406
+ limits: limits ? clone(limits) : null,
407
+ ...(strategy.legacy === true ? { legacy: true } : {}),
408
+ };
409
+ }
410
+
411
+ /**
412
+ * Refresh the descriptions of an active snapshot from the current session pool.
413
+ * Only `purpose`/`note` follow the pool; display names, models, version and every
414
+ * other field are preserved, so saving pool content never rewrites a strategy.
415
+ */
416
+ export function refreshSnapshotInfo(snapshot, pool) {
417
+ if (!snapshot) return null;
418
+ const byKey = new Map(pool.map((entry) => [routeKey(entry), entry]));
419
+ const modelsInfo = (snapshot.models || []).map((route) => {
420
+ const known = (snapshot.modelsInfo || []).find((info) => sameRoute(info, route));
421
+ const entry = byKey.get(routeKey(route));
422
+ return {
423
+ provider: route.provider,
424
+ model: route.model,
425
+ name: known?.name || entry?.name || route.model,
426
+ purpose: entry?.purpose || '',
427
+ note: entry?.note || '',
428
+ };
429
+ });
430
+ return { ...clone(snapshot), modelsInfo };
431
+ }
432
+
433
+ /** Snapshot content comparison used for "no modification means no version bump". */
434
+ export function snapshotContent(snapshot) {
435
+ if (!snapshot) return null;
436
+ return {
437
+ name: snapshot.name,
438
+ type: snapshot.type,
439
+ models: (snapshot.models || []).map(routeOf),
440
+ fixedProvider: snapshot.fixedProvider || '',
441
+ fixedModel: snapshot.fixedModel || '',
442
+ strategyText: snapshot.strategyText || '',
443
+ modelsInfo: (snapshot.modelsInfo || []).map((info) => ({
444
+ provider: info.provider, model: info.model, name: info.name, purpose: info.purpose || '', note: info.note || '',
445
+ })),
446
+ };
447
+ }
448
+
449
+ export function sameSnapshotContent(left, right) {
450
+ return JSON.stringify(snapshotContent(left)) === JSON.stringify(snapshotContent(right));
451
+ }
452
+
453
+ export function sameStrategyContent(left, right) {
454
+ if (!left || !right) return false;
455
+ return JSON.stringify(strategyContent(left)) === JSON.stringify(strategyContent(right));
456
+ }
457
+
458
+ export function strategyContent(strategy) {
459
+ return {
460
+ name: strategy.name,
461
+ type: strategy.type,
462
+ models: (strategy.models || []).map(routeOf),
463
+ fixedProvider: strategy.fixedProvider || '',
464
+ fixedModel: strategy.fixedModel || '',
465
+ strategyText: strategy.strategyText || '',
466
+ };
467
+ }
468
+
469
+ /** A strategy whose references left the pool (or whose fixed target did). */
470
+ export function stalePoolRefs(strategy, pool) {
471
+ const keys = new Set(pool.map(routeKey));
472
+ const stale = [];
473
+ for (const route of strategy.models || []) if (!keys.has(routeKey(route))) stale.push(route);
474
+ if (strategy.type === 'fixed' && strategy.fixedProvider && !keys.has(routeKey({ provider: strategy.fixedProvider, model: strategy.fixedModel }))) {
475
+ if (!stale.some((route) => sameRoute(route, { provider: strategy.fixedProvider, model: strategy.fixedModel }))) {
476
+ stale.push({ provider: strategy.fixedProvider, model: strategy.fixedModel });
477
+ }
478
+ }
479
+ return stale;
480
+ }
481
+
482
+ export function strategyNeedsAdjust(strategy, pool) {
483
+ return stalePoolRefs(strategy, pool).length > 0;
484
+ }
485
+
486
+ /** Saved strategy references must stay in the pool (existing stale refs may be kept). */
487
+ export function validateStrategyRefs(draft, pool, existing) {
488
+ const poolKeys = new Set(pool.map(routeKey));
489
+ const existingKeys = new Set(((existing && existing.models) || []).map(routeKey));
490
+ const missing = draft.models.filter((route) => !poolKeys.has(routeKey(route)) && !existingKeys.has(routeKey(route)));
491
+ if (missing.length) fail(`引用的模型不在会话池:${missing.map((route) => `${route.provider}/${route.model}`).join('、')}`);
492
+ return true;
493
+ }
494
+
495
+ export function assertStrategyNameFree(library, name, selfId) {
496
+ if (library.strategies.some((strategy) => strategy.id !== selfId && strategy.name === name)) fail(`已有同名策略:${name}`);
497
+ return true;
498
+ }
499
+
500
+ /** Shared gate for enabling a strategy and for saving the enabled strategy. */
501
+ export function validateForUse(strategy, pool, limits, { authorized, available, ceiling, hostActive = null, hostActiveKnown = false, hostActiveReason }) {
502
+ const stale = stalePoolRefs(strategy, pool);
503
+ if (stale.length) fail(`策略引用的模型不在会话池(${stale.map((route) => `${route.provider}/${route.model}`).join('、')});请先调整策略`);
504
+ const auth = new Set((authorized || []).map(routeKey));
505
+ const avail = new Set((available || []).map(routeKey));
506
+ for (const route of strategy.models) {
507
+ const key = routeKey(route);
508
+ if (!auth.has(key)) fail(`模型未获当前宿主授权:${route.provider}/${route.model}`);
509
+ if (!avail.has(key)) fail(`模型当前不可用:${route.provider}/${route.model}`);
510
+ }
511
+ if (!limits) fail('本会话尚未保存委派限制;请先在模型池保存后再启用或保存生效');
512
+ if (!Number.isSafeInteger(limits.maxDepth) || limits.maxDepth < 0 || limits.maxDepth > Math.min(32, ceiling)) {
513
+ fail(`已保存的最大递归深度 ${limits?.maxDepth} 超出宿主上限 ${Math.min(32, ceiling)};请先在模型池调整并保存`);
514
+ }
515
+ assertHostActive(limits.maxActive, { maxActive: hostActive, maxActiveKnown: hostActiveKnown, maxActiveReason: hostActiveReason });
516
+ // A legacy fixed strategy may keep several candidates; its target is not
517
+ // required to be the first route, only to be present.
518
+ if (strategy.type === 'fixed' && !strategy.models.some((route) => sameRoute(route, { provider: strategy.fixedProvider, model: strategy.fixedModel }))) fail('固定模型必须来自策略中选择的模型');
519
+ return true;
520
+ }
521
+
522
+ /**
523
+ * Validate a saved live-child limit against the real Host capacity.
524
+ * Unknown capacity is never treated as 32 or as "no limit".
525
+ */
526
+ export function assertHostActive(maxActive, { maxActive: hostActive = null, maxActiveKnown = false, maxActiveReason } = {}) {
527
+ if (!Number.isSafeInteger(maxActive) || maxActive < 1 || maxActive > PLUGIN_MAX_ACTIVE) fail(`并发子智能体数量必须是 1–${PLUGIN_MAX_ACTIVE} 的整数`);
528
+ if (!maxActiveKnown) fail(`无法确认宿主并发子智能体数量${maxActiveReason ? `(${maxActiveReason})` : ''};已拒绝保存/启用`);
529
+ const bound = Math.min(PLUGIN_MAX_ACTIVE, hostActive);
530
+ if (maxActive > bound) fail(`并发子智能体数量 ${maxActive} 超出宿主上限 ${bound};请先在模型池调整并保存`);
531
+ return true;
532
+ }
533
+
534
+ export function limitsConflict(limits, { maxDepth, maxActive = null, activeKnown = Number.isSafeInteger(maxActive) } = {}) {
535
+ if (!limits) return null;
536
+ const depthCeiling = Number.isSafeInteger(maxDepth) ? Math.min(32, maxDepth) : 32;
537
+ const activeCeiling = activeKnown && Number.isSafeInteger(maxActive) ? Math.min(PLUGIN_MAX_ACTIVE, maxActive) : null;
538
+ const depthOver = limits.maxDepth > depthCeiling;
539
+ const activeOver = activeCeiling !== null && limits.maxActive > activeCeiling;
540
+ if (!depthOver && !activeOver) return null;
541
+ return { depthOver, activeOver, maxDepth, maxActive, activeKnown };
542
+ }
543
+
544
+ export function defaultConflict(value, ceilings = {}) {
545
+ if (!value) return null;
546
+ const activeKnown = ceilings.maxActiveKnown === true
547
+ || (ceilings.maxActiveKnown === undefined && Number.isSafeInteger(ceilings.maxActive));
548
+ return limitsConflict(value.limits, { ...ceilings, activeKnown });
549
+ }
550
+
551
+ /** Capture a saved strategy as an independent new-session default. */
552
+ export function defaultFromStrategy(strategy, pool, limits, { now = Date.now(), sourceVersion = strategy.version } = {}) {
553
+ if (strategy.type === 'inherit') fail('跟随类型不能设为新会话默认;请先保存为固定或 Agent 自选策略');
554
+ const snapshot = snapshotForStrategy(strategy, pool, { now, limits });
555
+ return {
556
+ ...snapshot,
557
+ sourceId: strategy.id,
558
+ sourceName: strategy.name,
559
+ sourceVersion,
560
+ limits: limits ? clone(limits) : null,
561
+ };
562
+ }
563
+
564
+ /** Import the default into a brand-new root: independent copy, never auto-enabled. */
565
+ export function importDefault(value, { now = Date.now(), id, defaultRevision = null } = {}) {
566
+ if (!id) fail('导入默认缺少策略 id');
567
+ if (value.type === 'inherit') fail('默认内容不能是跟随类型');
568
+ const models = value.models.map(routeOf);
569
+ const pool = (value.modelsInfo || []).map((info) => ({
570
+ provider: info.provider,
571
+ model: info.model,
572
+ purpose: info.purpose || '',
573
+ note: info.note || '',
574
+ }));
575
+ for (const route of models) {
576
+ if (!pool.some((entry) => sameRoute(entry, route))) {
577
+ pool.push({ provider: route.provider, model: route.model, purpose: '', note: '' });
578
+ }
579
+ }
580
+ const strategy = {
581
+ id,
582
+ name: value.name,
583
+ type: value.type,
584
+ models,
585
+ fixedProvider: value.fixedProvider || '',
586
+ fixedModel: value.fixedModel || '',
587
+ strategyText: value.strategyText || '',
588
+ version: 1,
589
+ // A default captured from a v1 legacy multi-candidate fixed strategy keeps
590
+ // that explicit legacy form so it stays readable after materialization.
591
+ ...(value.legacy === true ? { legacy: true } : {}),
592
+ };
593
+ return {
594
+ version: LIBRARY_VERSION,
595
+ strategies: [strategy],
596
+ pool,
597
+ limits: value.limits ? clone(value.limits) : null,
598
+ active: null,
599
+ initialized: { at: now, defaultRevision, source: 'default' },
600
+ };
601
+ }
602
+
603
+ /** Pool save validation: additions need live Host authorization/availability. */
604
+ export function validatePoolSave(nextPool, previousPool, activeSnapshot, { authorized, available }) {
605
+ const prevKeys = new Set(previousPool.map(routeKey));
606
+ const auth = new Set((authorized || []).map(routeKey));
607
+ const avail = new Set((available || []).map(routeKey));
608
+ for (const entry of nextPool) {
609
+ const key = routeKey(entry);
610
+ if (!prevKeys.has(key)) {
611
+ if (!auth.has(key)) fail(`不能把未获宿主授权的模型加入会话池:${entry.provider}/${entry.model}`);
612
+ if (!avail.has(key)) fail(`不能把当前不可用的模型加入会话池:${entry.provider}/${entry.model}`);
613
+ }
614
+ }
615
+ if (activeSnapshot) {
616
+ const nextKeys = new Set(nextPool.map(routeKey));
617
+ const referenced = (activeSnapshot.models || []).filter((route) => !nextKeys.has(routeKey(route)));
618
+ if (referenced.length) {
619
+ fail(`无法保存:当前启用策略生效中的内容「${activeSnapshot.name}」仍引用 ${referenced.map((route) => `${route.provider}/${route.model}`).join('、')};请先调整该策略或关闭策略`);
620
+ }
621
+ }
622
+ return true;
623
+ }
624
+
625
+ /**
626
+ * Plan a "refresh candidates" sync: pool entries that are no longer supported
627
+ * at the moment the sync is initiated (not Host-authorized or currently
628
+ * unavailable) are removed instead of lingering because they were used before,
629
+ * and the same unsupported routes are pruned out of strategy references so the
630
+ * candidate list really shrinks instead of staying pinned by stale strategy
631
+ * models. A fixed strategy's routing target is kept even when unsupported.
632
+ * The applied snapshot of the currently enabled strategy is protected: if it
633
+ * references any route the sync would remove (from the pool or from a strategy
634
+ * row), the whole sync fails. Returns { kept, removed, pruned } where pruned
635
+ * is [{ id, name, models, dropped }] carrying each strategy's new models array.
636
+ */
637
+ export function planPoolSync(pool, { authorized, available }, activeSnapshot, strategies = []) {
638
+ const auth = new Set((authorized || []).map(routeKey));
639
+ const avail = new Set((available || []).map(routeKey));
640
+ const supported = (route) => auth.has(routeKey(route)) && avail.has(routeKey(route));
641
+ const kept = [];
642
+ const removed = [];
643
+ for (const entry of pool) {
644
+ if (supported(entry)) kept.push(entry);
645
+ else removed.push(entry);
646
+ }
647
+ const pruned = [];
648
+ const unsupported = new Set(removed.map(routeKey));
649
+ for (const strategy of strategies) {
650
+ const dropped = [];
651
+ const nextModels = [];
652
+ for (const route of strategy.models || []) {
653
+ const isTarget = strategy.type === 'fixed' && route.provider === strategy.fixedProvider && route.model === strategy.fixedModel;
654
+ if (!supported(route) && !isTarget) {
655
+ dropped.push(route);
656
+ unsupported.add(routeKey(route));
657
+ } else {
658
+ nextModels.push(route);
659
+ }
660
+ }
661
+ if (dropped.length) pruned.push({ id: strategy.id, name: strategy.name, models: nextModels, dropped });
662
+ }
663
+ if (activeSnapshot && unsupported.size) {
664
+ const referenced = (activeSnapshot.models || []).filter((route) => unsupported.has(routeKey(route)));
665
+ if (referenced.length) {
666
+ const names = [...new Set(referenced.map((route) => `${route.provider}/${route.model}`))];
667
+ fail(`无法刷新:当前启用策略生效中的内容「${activeSnapshot.name}」仍引用 ${names.join('、')};请先调整该策略或关闭策略后再刷新`);
668
+ }
669
+ }
670
+ return { kept, removed, pruned };
671
+ }
672
+
673
+ export { requestObject };