dsh-subagent-profile 0.4.0 → 0.5.1

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.
Files changed (37) hide show
  1. package/README.md +16 -57
  2. package/README.zh.md +16 -57
  3. package/index.mjs +97 -122
  4. package/lib/client.js +631 -88
  5. package/lib/core/cleanup.mjs +33 -0
  6. package/lib/core/data-maintenance.mjs +139 -0
  7. package/lib/core/decision-trace.mjs +20 -1
  8. package/lib/core/delegation.mjs +16 -0
  9. package/lib/core/dispatch-gates.mjs +122 -9
  10. package/lib/core/dispatch-schema.mjs +45 -7
  11. package/lib/core/dispatch-tool.mjs +30 -46
  12. package/lib/core/evolution-advice.mjs +31 -10
  13. package/lib/core/evolution-assets.mjs +13 -0
  14. package/lib/core/evolution-canary.mjs +427 -0
  15. package/lib/core/evolution-draft.mjs +21 -0
  16. package/lib/core/evolution-engine.mjs +176 -31
  17. package/lib/core/evolution-generate.mjs +7 -23
  18. package/lib/core/evolution-lag.mjs +78 -0
  19. package/lib/core/evolution-ledger.mjs +4 -1
  20. package/lib/core/evolution-routes.mjs +157 -16
  21. package/lib/core/http-routes.mjs +22 -7
  22. package/lib/core/model-policy.mjs +104 -0
  23. package/lib/core/presets-sync.mjs +24 -2
  24. package/lib/core/profile-directory.mjs +17 -0
  25. package/lib/core/profile-provider.mjs +7 -2
  26. package/lib/core/profiles-query.mjs +178 -0
  27. package/lib/core/profiles-store.mjs +40 -6
  28. package/lib/core/pure.mjs +1 -1
  29. package/lib/core/reminder-store.mjs +37 -3
  30. package/lib/core/snapshots.mjs +148 -0
  31. package/package.json +2 -2
  32. package/presets/orchestrator-v2/NOTICE +7 -0
  33. package/presets/{orchestrator → orchestrator-v2}/agent.cordis.yml +78 -7
  34. package/presets/orchestrator-v2/custom-bash.mjs +213 -0
  35. package/presets/orchestrator-v2/preset.yml +2 -0
  36. package/presets/orchestrator-v2/tool-bootstrap.mjs +620 -0
  37. package/presets/orchestrator/preset.yml +0 -2
@@ -10,14 +10,22 @@
10
10
  // 持久化 → 资产 draft→proposed;通过/拒绝两分支都写 suggestion-apply 审计。
11
11
  // 串行化:进程级按身份键单写锁(同键排队,跨键并行);候选池恒 system-trust
12
12
  // 白名单(不叠加逃生舱放行集);apply 只改新方案默认值,per-call 优先级不变。
13
+ // 金丝雀:apply 冻结基线(聚合 + 逐条分)与完整配置快照;观察期 K 次相关派发后
14
+ // 判定(跌破基线下沿 → 回滚建议只读挂面板,绝不自动回滚);回滚需 human_confirmed,
15
+ // 恢复快照 + suggestion-rollback 审计。滞后/回滚冷却在 evolution-lag.mjs,判定与
16
+ // 回滚本体在 evolution-canary.mjs,cold-start 状态经 evolutionStatus 暴露。
13
17
 
14
- import { join } from 'node:path';
18
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
19
+ import { basename, dirname, join } from 'node:path';
15
20
  import { sanitizeProfile } from './pure.mjs';
21
+ import { parseDispatchRecords } from './evolution-summary.mjs';
16
22
  import { assertCostGuard } from './cost-guard.mjs';
17
- import { profileFingerprint, sourceConfigFromKey } from './evolution-draft.mjs';
23
+ import { matchingRegistryProfiles, profileFingerprint, sourceConfigFromKey } from './evolution-draft.mjs';
18
24
  import { createEvolutionAssets } from './evolution-assets.mjs';
19
- import { computeAdvice, readSummaries, recentDispatchDetails } from './evolution-advice.mjs';
20
- import { generateCandidates, regenerateCandidates, matchingRegistryProfiles } from './evolution-generate.mjs';
25
+ import { computeAdvice, readSummaries, recentDispatchDetails, writeBackCooldowns, writeBackStreaks } from './evolution-advice.mjs';
26
+ import { generateCandidates, regenerateCandidates } from './evolution-generate.mjs';
27
+ import { CANARY_K, captureBaseline, captureFromSnapshot, evaluateCanary, executeRollback, evolutionStatusOf, holdoutBacktest, skippedGates } from './evolution-canary.mjs';
28
+ import { LAG_N, bumpStreak, createLagStreaks, directionOf, rollbackCooldownBlocks } from './evolution-lag.mjs';
21
29
 
22
30
  // --- 三道闸(apply 口径,对齐派发守卫同源逻辑)-------------------------------
23
31
 
@@ -85,11 +93,7 @@ async function assessApplyGates(deps, config) {
85
93
  }
86
94
 
87
95
  // --- 审计(suggestion-apply)--------------------------------------------------
88
-
89
- function skippedGates(reason) {
90
- const skipped = { verdict: 'skipped', reason };
91
- return { whitelist: skipped, cost: skipped, intersection: skipped, approval: 'never' };
92
- }
96
+ // skippedGates 与金丝雀域同源(evolution-canary.mjs),apply/回滚审计闸位口径一致。
93
97
 
94
98
  // suggestion-apply 事件:from/to/axis/direction/gates/human_confirmed/
95
99
  // baseline_snapshot 字段级齐备;拒绝分支附 reason。
@@ -122,8 +126,22 @@ function currentFingerprint(deps, asset) {
122
126
  return { ok: true, value: profileFingerprint({ profileKey: asset.profile_key, from, registryEntries: registry }) };
123
127
  }
124
128
 
125
- // 单次应用(在身份键锁内执行):确认 → 观察期隔离 → 指纹校验 → 三道闸 →
126
- // 落写 + 持久化 → 资产转 proposed;每个拒绝/通过分支都写审计。
129
+ // 应用数据充分性(冷启动门):该身份键聚合样本不足 3 → 拒绝应用(数据地基
130
+ // 缺失时应用/防退化不可用,状态经 evolutionStatus 暴露)。
131
+ function applyDataReadiness(deps, asset) {
132
+ const summaries = deps.getSummaries();
133
+ if (summaries === null) return { ok: false, reason: '聚合数据缺失,暂不支持应用(需积累派发数据)' };
134
+ const l1 = summaries.l1 !== null && typeof summaries.l1 === 'object' ? summaries.l1 : {};
135
+ const group = l1[asset.profile_key];
136
+ if (group === null || typeof group !== 'object') return { ok: false, reason: '聚合组缺失,暂不支持应用' };
137
+ const n = Number.isFinite(group.confidence?.n) ? group.confidence.n : (Number.isFinite(group.deployments_total) ? group.deployments_total : 0);
138
+ if (n < 3) return { ok: false, reason: `派发数据不足(当前 ${n} 次,需 ≥3),暂不支持应用` };
139
+ return { ok: true };
140
+ }
141
+
142
+ // 单次应用(在身份键锁内执行):确认 → 观察期隔离 → 指纹校验 → 数据充分性 →
143
+ // 三道闸 → 基线/快照冻结 → 落写 + 持久化 → 资产转 proposed(挂金丝雀初值);
144
+ // 每个拒绝/通过分支都写审计。
127
145
  async function applyOnce(deps, asset, humanConfirmed) {
128
146
  const audit = (entry) => deps.evoLedger.recordGovernanceAudit(entry);
129
147
  const reject = (reason, extra = {}) => {
@@ -150,6 +168,8 @@ async function applyOnce(deps, asset, humanConfirmed) {
150
168
  if (fingerprint.value !== asset.profile_fingerprint) {
151
169
  return expireAndReject('候选指纹失配:方案已被编辑或聚合数据已变化,需重新生成');
152
170
  }
171
+ const data = applyDataReadiness(deps, asset);
172
+ if (!data.ok) return reject(data.reason);
153
173
  if (deps.store.profiles.has(asset.config?.id)) {
154
174
  return reject('候选 profile id 已存在(请先删除或改名)');
155
175
  }
@@ -163,13 +183,57 @@ async function applyOnce(deps, asset, humanConfirmed) {
163
183
  const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
164
184
  return reject(`写入被拒绝:${detail}`, { gates: gates.gates });
165
185
  }
186
+ return applyWrite(deps, asset, clean, gates, audit);
187
+ }
188
+
189
+ // 落写 + 基线/快照冻结 + 资产转 proposed(挂金丝雀初值)+ 通过审计。基线/快照
190
+ // 在写前冻结:fromSnapshot = 完整配置快照(回滚恢复依据)、baseline = apply 时刻
191
+ // 聚合 + 逐条分(金丝雀下沿构造依据),时间隔离防自补偿。
192
+ async function applyWrite(deps, asset, clean, gates, audit) {
193
+ const now = Date.now();
194
+ const fromSnapshot = captureFromSnapshot(deps.store, asset.profile_key, now);
195
+ const baseline = captureBaseline({
196
+ summaries: deps.getSummaries(),
197
+ records: deps.getDispatchRecords?.() ?? [],
198
+ profileKey: asset.profile_key,
199
+ now,
200
+ });
166
201
  deps.store.profiles.set(clean.id, { ...clean, persisted: true });
167
202
  const persisted = deps.store.persistProfiles();
168
- deps.assets.transition(asset.id, 'proposed', { applied_at: Date.now() });
203
+ deps.assets.transition(asset.id, 'proposed', {
204
+ applied_at: now,
205
+ fromSnapshot,
206
+ baseline_metrics: { sampleN: baseline.sampleN ?? null, weighted_success: baseline.weighted_success ?? null },
207
+ applied_config: { ...clean },
208
+ canary: { k: 0, kTotal: CANARY_K, appliedAt: now, baseline, samples: [], verdict: null, stalled: false, insufficient: false },
209
+ });
169
210
  audit(applyAuditEntry(asset, { profileId: clean.id, gates: gates.gates, humanConfirmed: true }));
170
211
  return { ok: true, id: clean.id, persisted: persisted?.persisted === true, state: 'proposed', checks: gates.checks };
171
212
  }
172
213
 
214
+ // 台账轮转封存件文件名(与 evolution-persistence 轮转同口径)。
215
+ const ARCHIVE_PATTERN = /^dispatch-(\d+)-(\d+)(?:-\d+)?\.jsonl$/;
216
+
217
+ // 派发台账只读解析(金丝雀基线/样本数据源):活动台账 + 轮转封存件合并读取,观察
218
+ // 期跨轮转不丢样本;单文件缺失/损坏跳过,整体缺失回退空表,fail-soft。
219
+ function readDispatchRecords(file) {
220
+ if (typeof file !== 'string') return [];
221
+ const dir = dirname(file);
222
+ const names = [basename(file)];
223
+ try {
224
+ for (const name of readdirSync(dir)) if (ARCHIVE_PATTERN.test(name)) names.push(name);
225
+ } catch { /* 目录读失败只读活动台账 */ }
226
+ const records = [];
227
+ for (const name of names) {
228
+ const path = join(dir, name);
229
+ if (!existsSync(path)) continue;
230
+ try {
231
+ records.push(...parseDispatchRecords(readFileSync(path, 'utf8').split('\n')).records);
232
+ } catch { /* 单文件读失败跳过,不影响其余 */ }
233
+ }
234
+ return records;
235
+ }
236
+
173
237
  // 进程级按身份键单写锁:同键 apply 排队(并发只生效一次),跨键并行。
174
238
  function withApplyLock(locks, key, fn) {
175
239
  const previous = locks.get(key) ?? Promise.resolve();
@@ -187,10 +251,87 @@ function applyCandidate(deps, locks, { assetId, humanConfirmed }) {
187
251
  return withApplyLock(locks, key, () => applyOnce(deps, deps.assets.get(assetId), humanConfirmed === true));
188
252
  }
189
253
 
254
+ // 决议入口(受控路由专用;回滚=撤销已确认应用,keep=显式保留):读资产 → 身份键
255
+ // 锁内执行(与 apply 共用同一锁串行化)。不受 apply 开关门限制——决议是闭环收口路径。
256
+ function rollbackCandidate(deps, locks, { assetId, humanConfirmed, action = 'rollback' }) {
257
+ const asset = deps.assets.get(assetId);
258
+ if (asset === undefined) return { ok: false, reason: '回滚资产不存在(可能已过期或被清理)' };
259
+ const key = typeof asset.profile_key === 'string' ? asset.profile_key : assetId;
260
+ return withApplyLock(locks, key, () => executeRollback(deps, deps.assets.get(assetId), humanConfirmed === true, action));
261
+ }
262
+
263
+ // 金丝雀推进入口:遍历观察期(proposed + 金丝雀未终)资产逐条判定;幂等,可安全
264
+ // 随 /list 与资产列表多次调用。
265
+ function evaluateCanaries(deps, now = Date.now()) {
266
+ const results = [];
267
+ for (const asset of deps.assets.list()) {
268
+ if (asset !== null && typeof asset === 'object' && asset.state === 'proposed') {
269
+ results.push(evaluateCanary(deps, asset, now));
270
+ }
271
+ }
272
+ return results;
273
+ }
274
+
275
+ // 候选生成前置闸(滞后防振荡):cooldown 优先抑制(回滚冷却轮不计数),连续同方向
276
+ // 建议不足 N 次不产候选;人工强制键(重新生成候选)豁免。
277
+ async function generateWithLag(deps, input) {
278
+ const advice = Array.isArray(input?.advice) ? input.advice : [];
279
+ const summaries = input?.summaries ?? deps.getSummaries?.() ?? null;
280
+ const now = Date.now();
281
+ const gated = [];
282
+ const lagSkipped = [];
283
+ for (const item of advice) {
284
+ if (item === null || typeof item !== 'object') continue;
285
+ const forced = typeof input?.forceProfileKey === 'string' && input.forceProfileKey === item.profileKey;
286
+ if (!forced && rollbackCooldownBlocks(deps.assets.list(), item.profileKey, directionOf(item), now)) {
287
+ lagSkipped.push({ profileKey: item.profileKey, reason: 'rollback-cooldown' });
288
+ continue;
289
+ }
290
+ if (!forced && bumpStreak(deps.streaks, item, summaries, now) < LAG_N) {
291
+ lagSkipped.push({ profileKey: item.profileKey, reason: 'lag-not-reached' });
292
+ continue;
293
+ }
294
+ gated.push(item);
295
+ }
296
+ const result = await generateCandidates(deps, { advice: gated, summaries, forceProfileKey: input?.forceProfileKey });
297
+ return {
298
+ generated: result.generated ?? [],
299
+ skipped: [...lagSkipped, ...(result.skipped ?? [])],
300
+ ...(result.reason !== undefined ? { reason: result.reason } : {}),
301
+ };
302
+ }
303
+
304
+ // 引擎构造(从装配层拆出守行门):注入读台账/streak 落盘/回滚冷却注入/回滚建议提醒等闭环依赖。
305
+ function buildEngine({ ctx, store, catalog, assets, evoLedger, adviceWhitelist, logger, getEvolutionAdvice, applyEnabled, summariesFile, dispatchFile, pluginVersion, mintRollbackReminder }) {
306
+ return createEvolutionEngine({
307
+ store,
308
+ catalog,
309
+ assets,
310
+ evoLedger,
311
+ whitelist: adviceWhitelist,
312
+ logger,
313
+ getApplyEnabled: () => applyEnabled,
314
+ getAdviceEnabled: getEvolutionAdvice,
315
+ getAllowFailOpen: () => store.getAllowFailOpen(),
316
+ getLlm: () => ctx.get('llm'),
317
+ getSummaries: () => readSummaries(summariesFile, logger, { onLoss: () => evoLedger.markMigrationLoss() }),
318
+ getCandidateMode: () => store.loadCandidateMode(),
319
+ getCandidateTtlH: () => store.loadCandidateTtlH(),
320
+ getDispatchRecords: () => readDispatchRecords(dispatchFile),
321
+ saveStreaks: (list) => writeBackStreaks(summariesFile, list, logger),
322
+ injectRollbackCooldown: (profileKey, untilTs) => {
323
+ const data = readSummaries(summariesFile, logger, { onLoss: () => evoLedger.markMigrationLoss() });
324
+ writeBackCooldowns(summariesFile, data, [[profileKey, untilTs]], logger);
325
+ },
326
+ mintRollbackReminder,
327
+ pluginVersion,
328
+ });
329
+ }
330
+
190
331
  // 进化装配 helper:资产域 + 引擎 + 建议/候选生成 helper。adviceSource 是
191
332
  // /list(建议面板数据)的唯一入口——computeAdvice 之后随建议链开关把建议推导
192
333
  // 成候选资产(换代判定按用户设置),避免多处重复生成;生成失败只 warn 不影响
193
- // 建议展示(fail-soft)。注入段文本走 buildAdviceText。
334
+ // 建议展示(fail-soft)。面板文本走 renderAdviceText/buildAdviceText(模型侧注入下架)。
194
335
  export function createEvolutionAssembly({
195
336
  ctx,
196
337
  home,
@@ -199,6 +340,7 @@ export function createEvolutionAssembly({
199
340
  evoLedger,
200
341
  adviceWhitelist,
201
342
  getEvolutionAdvice,
343
+ getReminderStore,
202
344
  pluginVersion,
203
345
  }) {
204
346
  const logger = ctx.logger;
@@ -206,23 +348,15 @@ export function createEvolutionAssembly({
206
348
  const applyEnabled = store.loadApplyEnabled();
207
349
  const summariesFile = join(home, 'subagent-evolution', 'summaries.json');
208
350
  const dispatchFile = join(home, 'subagent-evolution', 'dispatch.jsonl');
209
- const engine = createEvolutionEngine({
210
- store,
211
- catalog,
212
- assets,
213
- evoLedger,
214
- whitelist: adviceWhitelist,
215
- logger,
216
- getApplyEnabled: () => applyEnabled,
217
- getAdviceEnabled: getEvolutionAdvice,
218
- getAllowFailOpen: () => store.getAllowFailOpen(),
219
- getLlm: () => ctx.get('llm'),
220
- getSummaries: () => readSummaries(summariesFile, logger, { onLoss: () => evoLedger.markMigrationLoss() }),
221
- getCandidateMode: () => store.loadCandidateMode(),
222
- getCandidateTtlH: () => store.loadCandidateTtlH(),
223
- pluginVersion,
224
- });
351
+ const engine = buildEngine({ ctx, store, catalog, assets, evoLedger, adviceWhitelist, logger, getEvolutionAdvice, applyEnabled, summariesFile, dispatchFile, pluginVersion, mintRollbackReminder: getReminderStore });
225
352
  const adviceSource = async () => {
353
+ // 金丝雀判定先于建议/候选生成:观察期满自动出 verdict(回滚建议只读挂面板),
354
+ // 失败只 warn 不阻断建议展示(fail-soft)。
355
+ try {
356
+ engine.evaluateCanaries();
357
+ } catch (error) {
358
+ logger.warn(`[dsh-subagent-profile] 金丝雀判定失败:${error instanceof Error ? error.message : String(error)}`);
359
+ }
226
360
  const result = computeAdvice({ summariesFile, dispatchFile, whitelist: adviceWhitelist, logger, onLoss: () => evoLedger.markMigrationLoss() });
227
361
  // 候选生成与建议链开关同生共死(同一 getter):面板开才生成与下发候选,
228
362
  // 面板关不悄悄生成;apply 开关只门应用写路径。
@@ -257,13 +391,24 @@ export function createEvolutionEngine({
257
391
  pluginVersion,
258
392
  getCandidateMode = () => 'auto',
259
393
  getCandidateTtlH = () => 24,
394
+ getDispatchRecords = () => [],
395
+ saveStreaks = () => {},
396
+ injectRollbackCooldown = () => {},
397
+ getHoldoutBacktest = () => holdoutBacktest,
398
+ mintRollbackReminder = () => {},
260
399
  }) {
261
- const deps = { store, catalog, assets, evoLedger, whitelist, logger, getApplyEnabled, getAdviceEnabled, getAllowFailOpen, getLlm, getSummaries, pluginVersion, getCandidateMode, getCandidateTtlH, assessGates: (config) => assessApplyGates(deps, config) };
262
400
  const applyLocks = new Map();
401
+ const streaks = createLagStreaks({ save: (list) => saveStreaks(list) });
402
+ const deps = { store, catalog, assets, evoLedger, whitelist, logger, getApplyEnabled, getAdviceEnabled, getAllowFailOpen, getLlm, getSummaries, pluginVersion, getCandidateMode, getCandidateTtlH, getDispatchRecords, saveStreaks, injectRollbackCooldown, getHoldoutBacktest, mintRollbackReminder, streaks, assessGates: (config) => assessApplyGates(deps, config) };
263
403
  return {
264
404
  list: () => assets.list(),
265
- generate: (input) => generateCandidates(deps, input),
405
+ generate: (input) => generateWithLag(deps, input),
266
406
  regenerate: (input) => regenerateCandidates(deps, input),
267
407
  apply: (input) => applyCandidate(deps, applyLocks, input),
408
+ rollback: (input) => rollbackCandidate(deps, applyLocks, input),
409
+ evaluateCanaries: (now = Date.now()) => evaluateCanaries(deps, now),
410
+ evolutionStatus: () => evolutionStatusOf({ getApplyEnabled: deps.getApplyEnabled, summaries: deps.getSummaries() }),
411
+ backtest: (records, candidateConfig, opts) => deps.getHoldoutBacktest()(records, candidateConfig, opts),
412
+ purgeCandidates: () => assets.purgeAll(),
268
413
  };
269
414
  }
@@ -4,34 +4,13 @@
4
4
  // 审计)再产新一代;手动维护不自动换代、不自动过期,只经「重新生成候选」刷新
5
5
  // (留审计)。纯函数 + 注入 deps,零 IO、零 LLM。
6
6
 
7
- import { sanitizeProfile } from './pure.mjs';
8
- import { profileKeyOf } from './evolution-summary.mjs';
9
- import { buildEvolutionCandidates, profileFingerprint, sourceConfigFromKey } from './evolution-draft.mjs';
7
+ import { buildEvolutionCandidates, matchingRegistryProfiles, profileFingerprint, sourceConfigFromKey } from './evolution-draft.mjs';
10
8
  import { candidateExpired, candidateExpiryOf, dataFingerprintOf, dataSignificantlyChanged, expireAuditEntry, refreshAuditEntry } from './evolution-renewal.mjs';
9
+ import { observationPeriodBlocked } from './evolution-lag.mjs';
11
10
 
12
11
  // 候选总量护栏:evolution 资产数达到 20 条即跳过生成并 warn(阈值待实测后校准)。
13
12
  const ASSET_CAP = 20;
14
13
 
15
- // 存量 profile 的 L1 身份键(与聚合层同口径:persona/toolFilter 只认存在性)。
16
- function keyCfg(profile) {
17
- const cfg = {};
18
- if (typeof profile.preset === 'string' && profile.preset !== '' && profile.preset !== 'inherit') cfg.preset = profile.preset;
19
- if (typeof profile.provider === 'string' && profile.provider !== '') cfg.provider = profile.provider;
20
- if (typeof profile.model === 'string' && profile.model !== '') cfg.model = profile.model;
21
- if (typeof profile.persona === 'string' && profile.persona.length > 0) cfg.persona_present = true;
22
- if (profile.toolFilter !== undefined && profile.toolFilter !== null) cfg.toolFilter_present = true;
23
- return cfg;
24
- }
25
-
26
- // 注册表中与该 L1 身份键匹配的方案(净化后快照)——指纹输入,编辑任一方案
27
- // 都会使候选过期。
28
- export function matchingRegistryProfiles(store, profileKey) {
29
- const profiles = store.profiles instanceof Map ? [...store.profiles.values()] : [];
30
- return profiles
31
- .filter((p) => p !== null && typeof p === 'object' && profileKeyOf(keyCfg(p)) === profileKey)
32
- .map((p) => sanitizeProfile(p, { strict: false }).clean);
33
- }
34
-
35
14
  // 单条候选 → 资产对象。baseline_snapshot 留档金丝雀基线(from + 生成时刻的加权分/
36
15
  // 样本数);data_fingerprint 留档换代判定输入;expiry 按用户设置(手动维护或不自动
37
16
  // 过期档为 null,否则 = 生成时刻 + 有效期小时数)。
@@ -173,7 +152,12 @@ export async function generateCandidates(deps, { advice, summaries, forceProfile
173
152
 
174
153
  // 重新生成候选(人工触发):该身份键所有未处置候选先转 expired,再产新一代;
175
154
  // 审计 suggestion-refresh 一条(人工触发即确认)。无当前建议时只清理不产新候选。
155
+ // 观察期内(proposed + 金丝雀未终)同键 skip:换代会销毁观察/回滚依据,不清理、
156
+ // 不产新一代、不写 refresh 审计(无动作即无留痕)。
176
157
  export async function regenerateCandidates(deps, { profileKey, advice, summaries }) {
158
+ if (observationPeriodBlocked(deps.assets.list(), profileKey)) {
159
+ return { expired: [], generated: [], skipped: [{ profileKey, reason: 'observation-period' }] };
160
+ }
177
161
  const unresolved = deps.assets.findUnresolved(profileKey);
178
162
  const expired = [];
179
163
  for (const entry of unresolved) {
@@ -0,0 +1,78 @@
1
+ // lib/core/evolution-lag.mjs — 防振荡域(滞后计数 + 回滚冷却 + 观察期联动判据)。
2
+ // 滞后 N:同 (profile_key, axis, direction) 连续 N 次同方向建议才放行候选生成
3
+ // (N=2 初值,待实测后校准);与建议链 10min cooldown 并存——cooldown 优先抑制,
4
+ // 被抑制的建议不计入连续次数。回滚后冷却:rolled_back 资产进同键冷却,冷却期内
5
+ // 不再产生同方向候选(闭环有界:同键 apply→rollback 往返最多 1 次进入冷却)。
6
+ // 观察期联动:观察期内(proposed + 金丝雀未终)同键禁止换代与重生成候选;无
7
+ // canary 字段的旧资产不阻断(保持既有「重新生成候选」通道)。
8
+ // 本域纯函数 + 注入保存:streak 缓存驻内存,落盘由装配层注入 save(fail-soft)。
9
+
10
+ // 滞后 N:连续同方向建议次数门槛(初值,待实测后校准)。
11
+ export const LAG_N = 2;
12
+ // 回滚后同键冷却时长(与建议链 cooldown 同值)。
13
+ export const ROLLBACK_COOLDOWN_MS = 10 * 60 * 1000;
14
+
15
+ // 建议方向 → 资产方向(down/up 与候选资产同口径)。
16
+ export function directionOf(item) {
17
+ return item !== null && typeof item === 'object' && item.suggestion === '升' ? 'up' : 'down';
18
+ }
19
+
20
+ // 建议方向 → 资产轴(capability/budget)。
21
+ export function axisOf(item) {
22
+ return item !== null && typeof item === 'object' && item.suggestion === '升' ? 'budget' : 'capability';
23
+ }
24
+
25
+ // 观察期是否进行中:proposed + 金丝雀未终且未因人工编辑失效。verdict 为 applied /
26
+ // aborted-human-edit 或资产 invalid 时视为已终;regress 等待人工回滚期间仍阻断
27
+ // (换代会销毁回滚建议依据,须先回滚或人工编辑)。
28
+ export function observationPeriodActive(asset) {
29
+ if (asset === null || typeof asset !== 'object' || asset.state !== 'proposed' || asset.invalid === true) return false;
30
+ const canary = asset.canary;
31
+ if (canary === null || typeof canary !== 'object') return false;
32
+ return canary.verdict !== 'applied' && canary.verdict !== 'aborted-human-edit';
33
+ }
34
+
35
+ // 资产列表(完整资产)中是否存在同键观察期资产。
36
+ export function observationPeriodBlocked(assetList, profileKey) {
37
+ return assetList.some((a) => a !== null && typeof a === 'object' && a.profile_key === profileKey && observationPeriodActive(a));
38
+ }
39
+
40
+ // 决议后冷却是否生效:同键同方向 rolled_back 资产(回滚决议)或带 cooldown_until
41
+ // 的 applied 资产(显式保留决议)在冷却期内抑制候选生成——两种决议都是闭环终点,
42
+ // 冷却口径一致(有界性:同键 apply→rollback 往返最多 1 次进入冷却)。
43
+ export function rollbackCooldownBlocks(assetList, profileKey, direction, now, cooldownMs = ROLLBACK_COOLDOWN_MS) {
44
+ return assetList.some((a) => a !== null && typeof a === 'object' && a.profile_key === profileKey && a.direction === direction
45
+ && ((a.state === 'rolled_back' && Number.isFinite(a.rollback_at) && now - a.rollback_at < cooldownMs)
46
+ || (a.state === 'applied' && Number.isFinite(a.cooldown_until) && now < a.cooldown_until)));
47
+ }
48
+
49
+ // streak 键:身份键 + 轴 + 方向(连续同方向计数按此聚合)。
50
+ function streakKeyOf(profileKey, axis, direction) {
51
+ return `${profileKey}\u0000${axis}\u0000${direction}`;
52
+ }
53
+
54
+ // 连续同方向建议计数:缓存未命中时读 summaries 里上次落盘的 streak(重启续数),
55
+ // 方向翻转重置为 1;计数封顶 LAG_N(防无限增长)。save 注入为批量落盘(fail-soft)。
56
+ export function bumpStreak(streaks, item, summaries, now) {
57
+ const profileKey = item.profileKey;
58
+ const axis = axisOf(item);
59
+ const direction = directionOf(item);
60
+ const key = streakKeyOf(profileKey, axis, direction);
61
+ let prev = streaks.cache.get(key);
62
+ if (prev === undefined) {
63
+ const l1 = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object' ? summaries.l1 : {};
64
+ const seeded = l1[profileKey]?.streak;
65
+ prev = seeded !== null && typeof seeded === 'object' && seeded.axis === axis && seeded.direction === direction ? seeded : null;
66
+ }
67
+ const count = prev !== null && typeof prev === 'object' && Number.isFinite(prev.count) ? Math.min(LAG_N, prev.count + 1) : 1;
68
+ const next = { profileKey, axis, direction, count, last_ts: now };
69
+ streaks.cache.set(key, next);
70
+ try { streaks.save([next]); } catch { /* 落盘失败不阻断生成(fail-soft) */ }
71
+ return count;
72
+ }
73
+
74
+ // streak 缓存工厂:save 注入落盘(装配层写回 summaries.json);缓存驻内存供
75
+ // 同进程连续轮次复用。
76
+ export function createLagStreaks({ save = () => {} } = {}) {
77
+ return { cache: new Map(), save };
78
+ }
@@ -82,7 +82,7 @@ function cfgOf(merged, opts = {}) {
82
82
 
83
83
  // 记录静态元数据(写入即知的共享字段,不含 ts/v——由 record 落盘时补)。三分支各自
84
84
  // 在此基础上补 child_id 与 outcome 后调用 record。pluginVersion 由工厂闭包注入。
85
- function buildBase({ parent, args, merged, mode, pluginVersion }) {
85
+ function buildBase({ parent, args, merged, mode, pluginVersion, effectiveModelSource }) {
86
86
  const prompt = typeof args?.prompt === 'string' ? args.prompt : '';
87
87
  const effectivePreset = mode === 'continuable' ? 'inherit' : (merged?.preset ?? 'inherit');
88
88
  // continuable 下宿主忽略 reasoningEffort(决策轨迹侧将其列入 ignored),生效栏
@@ -94,6 +94,9 @@ function buildBase({ parent, args, merged, mode, pluginVersion }) {
94
94
  session_id: parent?.session?.header?.id ?? null,
95
95
  parent_model: parent?.options?.model ?? null,
96
96
  parent_provider: parent?.options?.provider ?? null,
97
+ // 生效来源(call/profile/parent/default):每次派发记录「生效路由是谁决定的」;
98
+ // 调用方未传时归一 null,保持 JSONL 行形状稳定。
99
+ effectiveModelSource: typeof effectiveModelSource === 'string' && effectiveModelSource !== '' ? effectiveModelSource : null,
97
100
  mode,
98
101
  origin: ORIGIN,
99
102
  requested: cfgOf(merged),