dsh-subagent-profile 0.3.4 → 0.5.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/README.md +13 -8
- package/README.zh.md +13 -8
- package/index.mjs +99 -95
- package/lib/client.js +1288 -189
- package/lib/core/decision-trace.mjs +20 -1
- package/lib/core/delegation.mjs +3 -2
- package/lib/core/dispatch-gates.mjs +122 -9
- package/lib/core/dispatch-schema.mjs +45 -7
- package/lib/core/dispatch-tool.mjs +24 -25
- package/lib/core/evolution-advice.mjs +154 -34
- package/lib/core/evolution-assets.mjs +167 -0
- package/lib/core/evolution-canary.mjs +427 -0
- package/lib/core/evolution-draft.mjs +333 -0
- package/lib/core/evolution-engine.mjs +413 -0
- package/lib/core/evolution-generate.mjs +180 -0
- package/lib/core/evolution-lag.mjs +78 -0
- package/lib/core/evolution-ledger.mjs +135 -27
- package/lib/core/evolution-persistence.mjs +213 -0
- package/lib/core/evolution-renewal.mjs +64 -0
- package/lib/core/evolution-routes.mjs +234 -0
- package/lib/core/http-helpers.mjs +35 -0
- package/lib/core/http-routes.mjs +35 -39
- package/lib/core/model-policy.mjs +77 -0
- package/lib/core/presets-sync.mjs +24 -2
- package/lib/core/profile-directory.mjs +17 -0
- package/lib/core/profile-provider.mjs +9 -3
- package/lib/core/profiles-query.mjs +178 -0
- package/lib/core/profiles-store.mjs +56 -8
- package/lib/core/pure.mjs +1 -1
- package/lib/core/session-read.mjs +17 -0
- package/lib/core/shims.mjs +2 -1
- package/package.json +2 -2
- package/presets/orchestrator-v2/NOTICE +7 -0
- package/presets/{orchestrator → orchestrator-v2}/agent.cordis.yml +78 -7
- package/presets/orchestrator-v2/custom-bash.mjs +213 -0
- package/presets/orchestrator-v2/preset.yml +2 -0
- package/presets/orchestrator-v2/tool-bootstrap.mjs +620 -0
- package/presets/orchestrator/preset.yml +0 -2
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// lib/core/evolution-engine.mjs — 进化候选装配(生成 + 应用写路径)。
|
|
2
|
+
// 生成:computeAdvice 之后把每条建议推导成 ≤3 条互斥候选资产(evolution-draft),
|
|
3
|
+
// 逐候选过三道闸(复用 assessApplyGates 全量)后落资产域。更新策略与有效期用户
|
|
4
|
+
// 可配(state.json):自动换新在「候选到期且数据显著变化」时把旧候选转 expired
|
|
5
|
+
// (留审计)再产新一代;手动维护不自动换代、不自动过期,只经「重新生成候选」
|
|
6
|
+
// 刷新(留审计)。建议链开关(evolutionAdvice)与建议面板同生共死;apply 开关
|
|
7
|
+
// 只门应用写路径,不门生成。
|
|
8
|
+
// 应用:只从受控路由进来——人工确认 → 指纹校验(编辑后过期)→ 观察期隔离 →
|
|
9
|
+
// 三道闸(whitelist → cost → intersection,审批永不)→ 落写新 profile 并立即
|
|
10
|
+
// 持久化 → 资产 draft→proposed;通过/拒绝两分支都写 suggestion-apply 审计。
|
|
11
|
+
// 串行化:进程级按身份键单写锁(同键排队,跨键并行);候选池恒 system-trust
|
|
12
|
+
// 白名单(不叠加逃生舱放行集);apply 只改新方案默认值,per-call 优先级不变。
|
|
13
|
+
// 金丝雀:apply 冻结基线(聚合 + 逐条分)与完整配置快照;观察期 K 次相关派发后
|
|
14
|
+
// 判定(跌破基线下沿 → 回滚建议只读挂面板,绝不自动回滚);回滚需 human_confirmed,
|
|
15
|
+
// 恢复快照 + suggestion-rollback 审计。滞后/回滚冷却在 evolution-lag.mjs,判定与
|
|
16
|
+
// 回滚本体在 evolution-canary.mjs,cold-start 状态经 evolutionStatus 暴露。
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
19
|
+
import { basename, dirname, join } from 'node:path';
|
|
20
|
+
import { sanitizeProfile } from './pure.mjs';
|
|
21
|
+
import { parseDispatchRecords } from './evolution-summary.mjs';
|
|
22
|
+
import { assertCostGuard } from './cost-guard.mjs';
|
|
23
|
+
import { matchingRegistryProfiles, profileFingerprint, sourceConfigFromKey } from './evolution-draft.mjs';
|
|
24
|
+
import { createEvolutionAssets } from './evolution-assets.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';
|
|
29
|
+
|
|
30
|
+
// --- 三道闸(apply 口径,对齐派发守卫同源逻辑)-------------------------------
|
|
31
|
+
|
|
32
|
+
function whitelistGate(config, whitelist) {
|
|
33
|
+
const preset = typeof config.preset === 'string' && config.preset !== '' ? config.preset : null;
|
|
34
|
+
if (preset === null || preset === 'inherit') return { verdict: 'pass', reason: '未指定预设' };
|
|
35
|
+
const allowed = whitelist instanceof Set ? whitelist.has(preset) : Array.isArray(whitelist) && whitelist.includes(preset);
|
|
36
|
+
return allowed
|
|
37
|
+
? { verdict: 'pass' }
|
|
38
|
+
: { verdict: 'fail', reason: `目标预设 ${preset} 不在 system-trust 白名单(候选池恒 system-trust)` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 成本闸:硬上限 + 能力面核验(与派发 cost guard 同一实现);parent 用插件 ctx
|
|
42
|
+
// 占位(apply 无父会话,llm 取进程级服务)。失败不 throw,返回 verdict+reason。
|
|
43
|
+
async function costGate(config, llm, allowFailOpen, logger, catalog) {
|
|
44
|
+
const checks = [];
|
|
45
|
+
const parent = { ctx: { get: (name) => (name === 'llm' ? llm : undefined) }, options: {} };
|
|
46
|
+
try {
|
|
47
|
+
await assertCostGuard(parent, config, allowFailOpen, logger, catalog, (entry) => checks.push(entry));
|
|
48
|
+
return { verdict: 'pass', checks };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return { verdict: 'fail', reason: error instanceof Error ? error.message : String(error), checks };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 交集闸(apply 口径):apply 无父/子会话,真交集在派发时由宿主计算——此处按
|
|
55
|
+
// 工具目录作父集代理校验 toolFilter 只减不增:allow 必须全部是已知工具,
|
|
56
|
+
// 收窄后(去 deny/∩allow)不得为空(空集 fail-loud 语义与派发一致)。
|
|
57
|
+
function intersectionGate(config, toolNames) {
|
|
58
|
+
const tf = config.toolFilter;
|
|
59
|
+
if (tf === undefined || tf === null) {
|
|
60
|
+
return { verdict: 'pass', reason: '无工具过滤器(真实交集在派发时由宿主计算)' };
|
|
61
|
+
}
|
|
62
|
+
const allow = Array.isArray(tf.allow) ? tf.allow : [];
|
|
63
|
+
const deny = Array.isArray(tf.deny) ? tf.deny : [];
|
|
64
|
+
const known = new Set(toolNames);
|
|
65
|
+
const unknownAllow = allow.filter((name) => !known.has(name));
|
|
66
|
+
if (unknownAllow.length > 0) {
|
|
67
|
+
return { verdict: 'fail', reason: `toolFilter.allow 含未知工具:${unknownAllow.slice(0, 3).join('、')}` };
|
|
68
|
+
}
|
|
69
|
+
const effective = toolNames.filter((name) => !deny.includes(name) && (allow.length === 0 || allow.includes(name)));
|
|
70
|
+
if (effective.length === 0) {
|
|
71
|
+
return { verdict: 'fail', reason: '工具交集为空:toolFilter 收窄后无可派发工具,拒绝' };
|
|
72
|
+
}
|
|
73
|
+
return { verdict: 'pass' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 三道闸串行评估:whitelist → cost → intersection(approval 恒 'never')。
|
|
77
|
+
// 任一失败立即返回该闸 verdict+reason;未评估的后续闸保留 skipped 占位(审计
|
|
78
|
+
// gates 逐项 verdict 齐全);全过返回 gates 快照供审计。
|
|
79
|
+
async function assessApplyGates(deps, config) {
|
|
80
|
+
const skipped = { verdict: 'skipped', reason: '前序闸未通过,未评估' };
|
|
81
|
+
const gates = { whitelist: { ...skipped }, cost: { ...skipped }, intersection: { ...skipped }, approval: 'never' };
|
|
82
|
+
gates.whitelist = whitelistGate(config, deps.whitelist);
|
|
83
|
+
if (gates.whitelist.verdict === 'fail') return { ok: false, gates, reason: gates.whitelist.reason, checks: [] };
|
|
84
|
+
gates.cost = await costGate(config, deps.getLlm(), deps.getAllowFailOpen(), deps.logger, deps.catalog);
|
|
85
|
+
if (gates.cost.verdict === 'fail') return { ok: false, gates, reason: gates.cost.reason, checks: gates.cost.checks };
|
|
86
|
+
const snapshot = await deps.catalog.getSnapshot();
|
|
87
|
+
const toolNames = (Array.isArray(snapshot.tools) ? snapshot.tools : [])
|
|
88
|
+
.map((t) => t?.name)
|
|
89
|
+
.filter((name) => typeof name === 'string');
|
|
90
|
+
gates.intersection = intersectionGate(config, toolNames);
|
|
91
|
+
if (gates.intersection.verdict === 'fail') return { ok: false, gates, reason: gates.intersection.reason, checks: [] };
|
|
92
|
+
return { ok: true, gates, checks: gates.cost.checks };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- 审计(suggestion-apply)--------------------------------------------------
|
|
96
|
+
// skippedGates 与金丝雀域同源(evolution-canary.mjs),apply/回滚审计闸位口径一致。
|
|
97
|
+
|
|
98
|
+
// suggestion-apply 事件:from/to/axis/direction/gates/human_confirmed/
|
|
99
|
+
// baseline_snapshot 字段级齐备;拒绝分支附 reason。
|
|
100
|
+
function applyAuditEntry(asset, fields) {
|
|
101
|
+
const entry = {
|
|
102
|
+
event: 'suggestion-apply',
|
|
103
|
+
profile_id: fields.profileId !== undefined ? fields.profileId : asset.id,
|
|
104
|
+
from: asset.from,
|
|
105
|
+
to: asset.to,
|
|
106
|
+
axis: asset.axis,
|
|
107
|
+
direction: asset.direction,
|
|
108
|
+
gates: fields.gates ?? skippedGates(fields.reason ?? '未评估'),
|
|
109
|
+
human_confirmed: fields.humanConfirmed === true,
|
|
110
|
+
baseline_snapshot: asset.baseline_snapshot,
|
|
111
|
+
};
|
|
112
|
+
if (fields.reason !== undefined) entry.reason = fields.reason;
|
|
113
|
+
return entry;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- 应用写路径(受控路由专用;绝不自动落写)-----------------------------------
|
|
117
|
+
|
|
118
|
+
// 应用时刻指纹重算:读当前聚合(summaries)+ 当前注册表,与生成时刻指纹比对。
|
|
119
|
+
function currentFingerprint(deps, asset) {
|
|
120
|
+
const summaries = deps.getSummaries();
|
|
121
|
+
if (summaries === null) return { ok: false, reason: '聚合数据缺失,无法校验候选指纹' };
|
|
122
|
+
const l1 = summaries.l1 !== null && typeof summaries.l1 === 'object' ? summaries.l1 : {};
|
|
123
|
+
if (l1[asset.profile_key] === undefined) return { ok: false, reason: '候选指纹失配:聚合组已不存在' };
|
|
124
|
+
const from = sourceConfigFromKey(asset.profile_key, summaries.l2);
|
|
125
|
+
const registry = matchingRegistryProfiles(deps.store, asset.profile_key);
|
|
126
|
+
return { ok: true, value: profileFingerprint({ profileKey: asset.profile_key, from, registryEntries: registry }) };
|
|
127
|
+
}
|
|
128
|
+
|
|
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
|
+
// 每个拒绝/通过分支都写审计。
|
|
145
|
+
async function applyOnce(deps, asset, humanConfirmed) {
|
|
146
|
+
const audit = (entry) => deps.evoLedger.recordGovernanceAudit(entry);
|
|
147
|
+
const reject = (reason, extra = {}) => {
|
|
148
|
+
audit(applyAuditEntry(asset, { reason, humanConfirmed, ...extra }));
|
|
149
|
+
return { ok: false, reason, ...(extra.checks !== undefined ? { checks: extra.checks } : {}) };
|
|
150
|
+
};
|
|
151
|
+
const expireAndReject = (reason, extra = {}) => {
|
|
152
|
+
deps.assets.transition(asset.id, 'expired');
|
|
153
|
+
return reject(reason, extra);
|
|
154
|
+
};
|
|
155
|
+
if (asset.state === 'proposed' || asset.state === 'applied') {
|
|
156
|
+
return reject('该候选已处置(观察期或已应用),不能重复应用');
|
|
157
|
+
}
|
|
158
|
+
if (asset.state !== 'draft') return reject(`候选状态不允许应用(${String(asset.state)})`);
|
|
159
|
+
if (typeof asset.expiry === 'number' && Date.now() >= asset.expiry) {
|
|
160
|
+
return expireAndReject('候选已过期,需重新生成');
|
|
161
|
+
}
|
|
162
|
+
if (humanConfirmed !== true) return reject('必须人工确认后才能应用(human_confirmed=true)');
|
|
163
|
+
const otherProposed = deps.assets.findUnresolved(asset.profile_key)
|
|
164
|
+
.some((e) => e.id !== asset.id && e.state === 'proposed');
|
|
165
|
+
if (otherProposed) return reject('该方案处于观察期,禁止再次应用(观察期隔离)');
|
|
166
|
+
const fingerprint = currentFingerprint(deps, asset);
|
|
167
|
+
if (fingerprint.ok !== true) return expireAndReject(fingerprint.reason);
|
|
168
|
+
if (fingerprint.value !== asset.profile_fingerprint) {
|
|
169
|
+
return expireAndReject('候选指纹失配:方案已被编辑或聚合数据已变化,需重新生成');
|
|
170
|
+
}
|
|
171
|
+
const data = applyDataReadiness(deps, asset);
|
|
172
|
+
if (!data.ok) return reject(data.reason);
|
|
173
|
+
if (deps.store.profiles.has(asset.config?.id)) {
|
|
174
|
+
return reject('候选 profile id 已存在(请先删除或改名)');
|
|
175
|
+
}
|
|
176
|
+
const gates = await assessApplyGates(deps, asset.config ?? {});
|
|
177
|
+
if (!gates.ok) return reject(gates.reason, { gates: gates.gates, checks: gates.checks });
|
|
178
|
+
const { clean, warnings } = sanitizeProfile(
|
|
179
|
+
{ ...(asset.config ?? {}), name: asset.name, description: asset.description },
|
|
180
|
+
{ strict: true }
|
|
181
|
+
);
|
|
182
|
+
if (warnings.length > 0) {
|
|
183
|
+
const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
|
|
184
|
+
return reject(`写入被拒绝:${detail}`, { gates: gates.gates });
|
|
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
|
+
});
|
|
201
|
+
deps.store.profiles.set(clean.id, { ...clean, persisted: true });
|
|
202
|
+
const persisted = deps.store.persistProfiles();
|
|
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
|
+
});
|
|
210
|
+
audit(applyAuditEntry(asset, { profileId: clean.id, gates: gates.gates, humanConfirmed: true }));
|
|
211
|
+
return { ok: true, id: clean.id, persisted: persisted?.persisted === true, state: 'proposed', checks: gates.checks };
|
|
212
|
+
}
|
|
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
|
+
|
|
237
|
+
// 进程级按身份键单写锁:同键 apply 排队(并发只生效一次),跨键并行。
|
|
238
|
+
function withApplyLock(locks, key, fn) {
|
|
239
|
+
const previous = locks.get(key) ?? Promise.resolve();
|
|
240
|
+
const run = previous.then(fn, fn);
|
|
241
|
+
locks.set(key, run.catch(() => {}));
|
|
242
|
+
return run;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 应用入口:apply 开关门 → 读资产 → 在身份键锁内执行单次应用。
|
|
246
|
+
function applyCandidate(deps, locks, { assetId, humanConfirmed }) {
|
|
247
|
+
if (!deps.getApplyEnabled()) return { ok: false, reason: '进化 apply 开关未开启(默认关,可在设置页开启)' };
|
|
248
|
+
const asset = deps.assets.get(assetId);
|
|
249
|
+
if (asset === undefined) return { ok: false, reason: '候选资产不存在(可能已过期或被清理)' };
|
|
250
|
+
const key = typeof asset.profile_key === 'string' ? asset.profile_key : assetId;
|
|
251
|
+
return withApplyLock(locks, key, () => applyOnce(deps, deps.assets.get(assetId), humanConfirmed === true));
|
|
252
|
+
}
|
|
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
|
+
|
|
331
|
+
// 进化装配 helper:资产域 + 引擎 + 建议/候选生成 helper。adviceSource 是
|
|
332
|
+
// /list(建议面板数据)的唯一入口——computeAdvice 之后随建议链开关把建议推导
|
|
333
|
+
// 成候选资产(换代判定按用户设置),避免多处重复生成;生成失败只 warn 不影响
|
|
334
|
+
// 建议展示(fail-soft)。面板文本走 renderAdviceText/buildAdviceText(模型侧注入下架)。
|
|
335
|
+
export function createEvolutionAssembly({
|
|
336
|
+
ctx,
|
|
337
|
+
home,
|
|
338
|
+
store,
|
|
339
|
+
catalog,
|
|
340
|
+
evoLedger,
|
|
341
|
+
adviceWhitelist,
|
|
342
|
+
getEvolutionAdvice,
|
|
343
|
+
getReminderStore,
|
|
344
|
+
pluginVersion,
|
|
345
|
+
}) {
|
|
346
|
+
const logger = ctx.logger;
|
|
347
|
+
const assets = createEvolutionAssets({ dshHome: home, logger, onGovernanceFailure: () => evoLedger.markGovernanceFailure() });
|
|
348
|
+
const applyEnabled = store.loadApplyEnabled();
|
|
349
|
+
const summariesFile = join(home, 'subagent-evolution', 'summaries.json');
|
|
350
|
+
const dispatchFile = join(home, 'subagent-evolution', 'dispatch.jsonl');
|
|
351
|
+
const engine = buildEngine({ ctx, store, catalog, assets, evoLedger, adviceWhitelist, logger, getEvolutionAdvice, applyEnabled, summariesFile, dispatchFile, pluginVersion, mintRollbackReminder: getReminderStore });
|
|
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
|
+
}
|
|
360
|
+
const result = computeAdvice({ summariesFile, dispatchFile, whitelist: adviceWhitelist, logger, onLoss: () => evoLedger.markMigrationLoss() });
|
|
361
|
+
// 候选生成与建议链开关同生共死(同一 getter):面板开才生成与下发候选,
|
|
362
|
+
// 面板关不悄悄生成;apply 开关只门应用写路径。
|
|
363
|
+
let generated = { generated: [], skipped: [] };
|
|
364
|
+
let candidates = [];
|
|
365
|
+
if (getEvolutionAdvice()) {
|
|
366
|
+
try {
|
|
367
|
+
generated = await engine.generate({ advice: result.advice, summaries: result.summaries });
|
|
368
|
+
candidates = engine.list().filter((a) => a !== null && typeof a === 'object' && a.source === 'evolution' && a.state === 'draft');
|
|
369
|
+
} catch (error) {
|
|
370
|
+
logger.warn(`[dsh-subagent-profile] 进化候选生成失败:${error instanceof Error ? error.message : String(error)}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const adviceDetails = recentDispatchDetails(dispatchFile, result.advice.map((a) => a.profileKey), { limit: 5 }); // 近期派发明细:按建议键读台账最近 5 次(只含渲染字段,无原文)
|
|
374
|
+
return { ...result, generated, candidates, adviceDetails };
|
|
375
|
+
};
|
|
376
|
+
return { assets, evolution: engine, adviceSource };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function createEvolutionEngine({
|
|
380
|
+
store,
|
|
381
|
+
catalog,
|
|
382
|
+
assets,
|
|
383
|
+
evoLedger,
|
|
384
|
+
whitelist,
|
|
385
|
+
logger,
|
|
386
|
+
getApplyEnabled,
|
|
387
|
+
getAdviceEnabled,
|
|
388
|
+
getAllowFailOpen,
|
|
389
|
+
getLlm,
|
|
390
|
+
getSummaries,
|
|
391
|
+
pluginVersion,
|
|
392
|
+
getCandidateMode = () => 'auto',
|
|
393
|
+
getCandidateTtlH = () => 24,
|
|
394
|
+
getDispatchRecords = () => [],
|
|
395
|
+
saveStreaks = () => {},
|
|
396
|
+
injectRollbackCooldown = () => {},
|
|
397
|
+
getHoldoutBacktest = () => holdoutBacktest,
|
|
398
|
+
mintRollbackReminder = () => {},
|
|
399
|
+
}) {
|
|
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) };
|
|
403
|
+
return {
|
|
404
|
+
list: () => assets.list(),
|
|
405
|
+
generate: (input) => generateWithLag(deps, input),
|
|
406
|
+
regenerate: (input) => regenerateCandidates(deps, input),
|
|
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
|
+
};
|
|
413
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// lib/core/evolution-generate.mjs — 候选生成路径(从 evolution-engine 拆出守行门):
|
|
2
|
+
// 一条建议 → ≤3 条互斥候选,逐候选过三道闸(闸由装配层注入)后落资产域。更新策略
|
|
3
|
+
// 与有效期用户可配:自动换新在「候选到期且数据显著变化」时把旧候选转 expired(留
|
|
4
|
+
// 审计)再产新一代;手动维护不自动换代、不自动过期,只经「重新生成候选」刷新
|
|
5
|
+
// (留审计)。纯函数 + 注入 deps,零 IO、零 LLM。
|
|
6
|
+
|
|
7
|
+
import { buildEvolutionCandidates, matchingRegistryProfiles, profileFingerprint, sourceConfigFromKey } from './evolution-draft.mjs';
|
|
8
|
+
import { candidateExpired, candidateExpiryOf, dataFingerprintOf, dataSignificantlyChanged, expireAuditEntry, refreshAuditEntry } from './evolution-renewal.mjs';
|
|
9
|
+
import { observationPeriodBlocked } from './evolution-lag.mjs';
|
|
10
|
+
|
|
11
|
+
// 候选总量护栏:evolution 资产数达到 20 条即跳过生成并 warn(阈值待实测后校准)。
|
|
12
|
+
const ASSET_CAP = 20;
|
|
13
|
+
|
|
14
|
+
// 单条候选 → 资产对象。baseline_snapshot 留档金丝雀基线(from + 生成时刻的加权分/
|
|
15
|
+
// 样本数);data_fingerprint 留档换代判定输入;expiry 按用户设置(手动维护或不自动
|
|
16
|
+
// 过期档为 null,否则 = 生成时刻 + 有效期小时数)。
|
|
17
|
+
export function buildAsset(deps, item, draft, now, summaries) {
|
|
18
|
+
const l1Entry = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object'
|
|
19
|
+
? summaries.l1[item.profileKey]
|
|
20
|
+
: undefined;
|
|
21
|
+
const l2 = summaries !== null && typeof summaries === 'object' ? summaries.l2 : undefined;
|
|
22
|
+
if (draft === null || typeof draft !== 'object') return null;
|
|
23
|
+
const from = sourceConfigFromKey(item.profileKey, l2);
|
|
24
|
+
const registry = matchingRegistryProfiles(deps.store, item.profileKey);
|
|
25
|
+
const fingerprint = profileFingerprint({ profileKey: item.profileKey, from, registryEntries: registry });
|
|
26
|
+
const axis = item.suggestion === '降' ? 'capability' : 'budget';
|
|
27
|
+
const direction = item.suggestion === '降' ? 'down' : 'up';
|
|
28
|
+
return {
|
|
29
|
+
v: 1,
|
|
30
|
+
id: draft.config.id,
|
|
31
|
+
source: 'evolution',
|
|
32
|
+
provenance: { pluginVersion: deps.pluginVersion, generatedAt: now, basis: draft.basis },
|
|
33
|
+
state: 'draft',
|
|
34
|
+
version: 1,
|
|
35
|
+
expiry: candidateExpiryOf(deps.getCandidateMode(), deps.getCandidateTtlH(), now),
|
|
36
|
+
from,
|
|
37
|
+
to: draft.config,
|
|
38
|
+
axis,
|
|
39
|
+
direction,
|
|
40
|
+
baseline_snapshot: {
|
|
41
|
+
from,
|
|
42
|
+
score: l1Entry?.score ?? null,
|
|
43
|
+
n: l1Entry?.confidence?.n ?? 0,
|
|
44
|
+
confidence: item.confidence ?? '',
|
|
45
|
+
generatedAt: now,
|
|
46
|
+
},
|
|
47
|
+
data_fingerprint: dataFingerprintOf(item, summaries),
|
|
48
|
+
profile_key: item.profileKey,
|
|
49
|
+
profile_fingerprint: fingerprint,
|
|
50
|
+
config: draft.config,
|
|
51
|
+
name: draft.name,
|
|
52
|
+
description: draft.description,
|
|
53
|
+
createdAt: now,
|
|
54
|
+
updatedAt: now,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 单条建议 → ≤3 条候选,逐候选过三道闸(复用装配层注入的闸评估)后落盘;单条
|
|
59
|
+
// 候选闸不过或落盘失败只跳过该候选(fail-soft),不影响其余候选与建议展示。
|
|
60
|
+
async function generateCandidatesFor(deps, { item, snapshot, taken, now, summaries }) {
|
|
61
|
+
const l1Entry = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object'
|
|
62
|
+
? summaries.l1[item.profileKey]
|
|
63
|
+
: undefined;
|
|
64
|
+
const l2 = summaries !== null && typeof summaries === 'object' ? summaries.l2 : undefined;
|
|
65
|
+
const drafts = buildEvolutionCandidates({
|
|
66
|
+
advice: item,
|
|
67
|
+
l1Entry,
|
|
68
|
+
l2,
|
|
69
|
+
snapshot,
|
|
70
|
+
whitelist: deps.whitelist,
|
|
71
|
+
takenIds: taken,
|
|
72
|
+
now,
|
|
73
|
+
});
|
|
74
|
+
const generated = [];
|
|
75
|
+
const skipped = [];
|
|
76
|
+
for (const draft of drafts) {
|
|
77
|
+
const gates = await deps.assessGates(draft.config);
|
|
78
|
+
if (!gates.ok) {
|
|
79
|
+
skipped.push({ profileKey: item.profileKey, candidateId: draft.config.id, reason: gates.reason });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const asset = buildAsset(deps, item, draft, now, summaries);
|
|
83
|
+
if (asset === null) continue;
|
|
84
|
+
const persisted = deps.assets.add(asset);
|
|
85
|
+
if (persisted.persisted) {
|
|
86
|
+
taken.add(asset.id);
|
|
87
|
+
generated.push(asset.id);
|
|
88
|
+
} else {
|
|
89
|
+
skipped.push({ profileKey: item.profileKey, candidateId: draft.config.id, reason: 'persist-failed' });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { generated, skipped };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// auto 模式的待处置门槛:null = 可生成;{skip} = 幂等跳过;{expire} = 需先转
|
|
96
|
+
// expired 再换代。观察期(proposed)恒阻断;手动维护不自动换代。
|
|
97
|
+
function pendingGate(deps, item, now, summaries, mode) {
|
|
98
|
+
const unresolved = deps.assets.findUnresolved(item.profileKey);
|
|
99
|
+
if (unresolved.length === 0) return null;
|
|
100
|
+
if (mode === 'manual' || unresolved.some((entry) => entry.state === 'proposed')) return { skip: 'pending' };
|
|
101
|
+
const expired = unresolved.filter((entry) => candidateExpired(entry, now));
|
|
102
|
+
if (expired.length < unresolved.length) return { skip: 'pending' };
|
|
103
|
+
const next = dataFingerprintOf(item, summaries);
|
|
104
|
+
const changed = expired.some((entry) => {
|
|
105
|
+
const asset = deps.assets.get(entry.id);
|
|
106
|
+
return dataSignificantlyChanged(asset !== undefined ? (asset.data_fingerprint ?? {}) : {}, next);
|
|
107
|
+
});
|
|
108
|
+
return changed ? { expire: expired } : { skip: 'no-significant-change' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 生成候选(建议 → 资产域):建议链开关门(evolutionAdvice)→ 总量护栏 → 逐条建议:
|
|
112
|
+
// auto:无未处置候选才生成;未到期幂等跳过;全部到期且数据显著变化 → 旧候选转
|
|
113
|
+
// expired(留审计)再产新一代;到期但数据没显著变化 → 保留旧候选跳过。
|
|
114
|
+
// manual:有任何未处置候选即幂等跳过(不自动换代、不自动过期)。
|
|
115
|
+
// forceProfileKey:只对该键绕过上述待处置门槛(「重新生成候选」内部复用)。
|
|
116
|
+
export async function generateCandidates(deps, { advice, summaries, forceProfileKey = null } = {}) {
|
|
117
|
+
if (!deps.getAdviceEnabled()) return { generated: [], skipped: [], reason: 'advice-disabled' };
|
|
118
|
+
const snapshot = await deps.catalog.getSnapshot();
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
const mode = deps.getCandidateMode();
|
|
121
|
+
const evolutionCount = deps.assets.countBySource('evolution');
|
|
122
|
+
if (evolutionCount >= ASSET_CAP) {
|
|
123
|
+
deps.logger.warn('[dsh-subagent-profile] 候选总量护栏:evolution 资产已达 20 条上限,跳过生成(阈值待实测后校准)');
|
|
124
|
+
return { generated: [], skipped: [] };
|
|
125
|
+
}
|
|
126
|
+
const taken = new Set([...deps.store.profiles.keys(), ...deps.assets.ids()]);
|
|
127
|
+
const generated = [];
|
|
128
|
+
const skipped = [];
|
|
129
|
+
for (const item of Array.isArray(advice) ? advice : []) {
|
|
130
|
+
if (evolutionCount + generated.length >= ASSET_CAP) break;
|
|
131
|
+
const forced = forceProfileKey !== null && item.profileKey === forceProfileKey;
|
|
132
|
+
if (!forced) {
|
|
133
|
+
const gate = pendingGate(deps, item, now, summaries, mode);
|
|
134
|
+
if (gate === null) { /* 无未处置候选,正常生成 */ }
|
|
135
|
+
else if (gate.skip !== undefined) { skipped.push({ profileKey: item.profileKey, reason: gate.skip }); continue; }
|
|
136
|
+
else {
|
|
137
|
+
for (const entry of gate.expire) {
|
|
138
|
+
const transitioned = deps.assets.transition(entry.id, 'expired', { expire_reason: 'expiry-and-data-change' });
|
|
139
|
+
if (transitioned.persisted) {
|
|
140
|
+
const asset = deps.assets.get(entry.id);
|
|
141
|
+
if (asset !== undefined) deps.evoLedger.recordGovernanceAudit(expireAuditEntry(asset, '候选到期且数据显著变化,已换新'));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const perItem = await generateCandidatesFor(deps, { item, snapshot, taken, now, summaries });
|
|
147
|
+
generated.push(...perItem.generated);
|
|
148
|
+
skipped.push(...perItem.skipped);
|
|
149
|
+
}
|
|
150
|
+
return { generated, skipped };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 重新生成候选(人工触发):该身份键所有未处置候选先转 expired,再产新一代;
|
|
154
|
+
// 审计 suggestion-refresh 一条(人工触发即确认)。无当前建议时只清理不产新候选。
|
|
155
|
+
// 观察期内(proposed + 金丝雀未终)同键 skip:换代会销毁观察/回滚依据,不清理、
|
|
156
|
+
// 不产新一代、不写 refresh 审计(无动作即无留痕)。
|
|
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
|
+
}
|
|
161
|
+
const unresolved = deps.assets.findUnresolved(profileKey);
|
|
162
|
+
const expired = [];
|
|
163
|
+
for (const entry of unresolved) {
|
|
164
|
+
const transitioned = deps.assets.transition(entry.id, 'expired', { expire_reason: 'manual-regenerate' });
|
|
165
|
+
if (transitioned.persisted) expired.push(entry.id);
|
|
166
|
+
}
|
|
167
|
+
const result = await generateCandidates(deps, { advice, summaries, forceProfileKey: profileKey });
|
|
168
|
+
const generatedIds = Array.isArray(result.generated) ? result.generated : [];
|
|
169
|
+
const item = (Array.isArray(advice) ? advice : []).find((a) => a !== null && typeof a === 'object' && a.profileKey === profileKey);
|
|
170
|
+
const l2 = summaries !== null && typeof summaries === 'object' ? summaries.l2 : undefined;
|
|
171
|
+
const first = generatedIds.length > 0 ? deps.assets.get(generatedIds[0]) : undefined;
|
|
172
|
+
deps.evoLedger.recordGovernanceAudit(refreshAuditEntry({
|
|
173
|
+
profileKey,
|
|
174
|
+
from: sourceConfigFromKey(profileKey, l2),
|
|
175
|
+
to: first !== undefined ? first.to : null,
|
|
176
|
+
axis: item?.suggestion === '升' ? 'budget' : 'capability',
|
|
177
|
+
direction: item?.suggestion === '升' ? 'up' : 'down',
|
|
178
|
+
}));
|
|
179
|
+
return { expired, generated: generatedIds, skipped: Array.isArray(result.skipped) ? result.skipped : [] };
|
|
180
|
+
}
|