dsh-subagent-profile 0.3.2 → 0.3.3
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 +38 -29
- package/README.zh.md +38 -29
- package/index.mjs +188 -62
- package/lib/client.js +1240 -47
- package/lib/core/catalog-cache.mjs +45 -7
- package/lib/core/catalog.mjs +6 -6
- package/lib/core/cost-guard.mjs +71 -44
- package/lib/core/decision-trace.mjs +433 -0
- package/lib/core/delegation.mjs +106 -48
- package/lib/core/dispatch-gates.mjs +146 -0
- package/lib/core/dispatch-guard.mjs +150 -0
- package/lib/core/dispatch-schema.mjs +98 -14
- package/lib/core/dispatch-tool.mjs +208 -199
- package/lib/core/escape.mjs +130 -0
- package/lib/core/evolution-ledger.mjs +289 -0
- package/lib/core/evolution-summary.mjs +432 -0
- package/lib/core/http-routes.mjs +155 -40
- package/lib/core/intersection.mjs +6 -9
- package/lib/core/presets-sync.mjs +256 -136
- package/lib/core/profile-provider.mjs +41 -39
- package/lib/core/profiles-store.mjs +103 -76
- package/lib/core/pure.mjs +110 -66
- package/lib/core/shims.mjs +56 -75
- package/lib/core/whitelist.mjs +23 -17
- package/package.json +2 -3
- package/presets/orchestrator/agent.cordis.yml +243 -271
- package/presets/orchestrator/NOTICE +0 -3
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// lib/core/evolution-summary.mjs — 派发台账(dispatch.jsonl)的确定性双层聚合
|
|
2
|
+
// (summaries.json)。纯函数为主:给定输入完全决定输出,可复算、可复现。IO 仅在
|
|
3
|
+
// writeSummaries(原子写 tmp+rename)与顶层装配层出现;computeSummaries 不读文件。
|
|
4
|
+
//
|
|
5
|
+
// 输入口径与派发台账一致(见 evolution-ledger.mjs):每条 record 为 dispatch.jsonl
|
|
6
|
+
// 一行的解析结果,含 requested/effective 双栏 cfg(preset/model/provider/
|
|
7
|
+
// reasoningEffort/maxTokens/persona_present/toolFilter_present 等)与可选 outcome
|
|
8
|
+
// {status|elapsed_ms|output_len|tool_count}。continuable 无结算(无 outcome 键),
|
|
9
|
+
// 不参与 deployments_total/性能聚合,但参与轴分布。
|
|
10
|
+
//
|
|
11
|
+
// 不变量(可复算):同输入恒同输出,遍历序稳定;聚合只读台账、不写回;parent_adopted
|
|
12
|
+
// 等动态质量信号不在台账内(写入当时不存在),由聚合层惰性 join(本模块只留字段
|
|
13
|
+
// 占位与可选输入 seam),原始台账本身不注入。
|
|
14
|
+
|
|
15
|
+
// 仅 writeSummaries/readSummaries/buildAdviceText 需要 IO(node 内置 fs),与台账采集
|
|
16
|
+
// 同种授权面;纯聚合/建议函数零 IO。
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { dirname } from 'node:path';
|
|
19
|
+
|
|
20
|
+
// --- 加权成功分 ---------------------------------------------------------------
|
|
21
|
+
// 公式:weighted_success = (completed − 0.3·confirmedFalse) / total,下限 0,小数点 3
|
|
22
|
+
// 位。每例「已确认未被采纳」的派发(parentAdoptedConfirmedFalse)从分子扣 0.3;
|
|
23
|
+
// unknown(未决)计 0 不惩罚——只有确认 false 才有惩罚,避免窗口期未判决的派发过早
|
|
24
|
+
// 拉低分数。total = completed+failed+killed。
|
|
25
|
+
export function weightedSuccess({ completed, failed, killed, parentAdoptedConfirmedFalse = 0 }) {
|
|
26
|
+
const total = completed + failed + killed;
|
|
27
|
+
if (total <= 0) return 0;
|
|
28
|
+
const falseCount = Number.isFinite(parentAdoptedConfirmedFalse) ? parentAdoptedConfirmedFalse : 0;
|
|
29
|
+
const numerator = Math.max(0, completed - 0.3 * Math.max(0, falseCount));
|
|
30
|
+
return Number((numerator / total).toFixed(3));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- 采纳判定窗口(三态)------------------------------------------------------
|
|
34
|
+
// 参数化判定窗口:窗口内(尚未超过 N 轮 或 T ms)未引用一律 unknown;窗口外已确认未
|
|
35
|
+
// 引用(adoptedAt 为 null)落 false;adoptedAt 有值恒 true。N/T 待实测后校准,故均为
|
|
36
|
+
// 参数而非常量。
|
|
37
|
+
export function adoptStatus({ windowN = null, windowMs = null, dispatchedAt, adoptedAt, now, roundsElapsed = null }) {
|
|
38
|
+
if (adoptedAt !== null && adoptedAt !== undefined) return 'true';
|
|
39
|
+
const base = dispatchedAt !== null && dispatchedAt !== undefined ? dispatchedAt : now;
|
|
40
|
+
const withinMs = windowMs === null || (now - base) < windowMs;
|
|
41
|
+
const withinRounds = windowN === null || (roundsElapsed !== null ? roundsElapsed : 0) < windowN;
|
|
42
|
+
return withinMs && withinRounds ? 'unknown' : 'false';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- 置信度级别 ---------------------------------------------------------------
|
|
46
|
+
// 阈值(3/10)与 min_n_required 为建议初值,待实测后校准。
|
|
47
|
+
export function confidenceLevel(n, minN = 3, highN = 10) {
|
|
48
|
+
if (n >= highN) return 'high';
|
|
49
|
+
if (n >= minN) return 'medium';
|
|
50
|
+
return 'low';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- profile 身份与轴提取(确定性)-------------------------------------------
|
|
54
|
+
// L1 key = 生效配置重建的 profile 身份(台账不存 profileId,故从 effective cfg 重建);
|
|
55
|
+
// 无区分性配置时记 '(inline)'。能力轴(preset/provider/model/persona/toolFilter)构成
|
|
56
|
+
// 身份;预算轴 effort 不并入(同一配置不同档位不拆组)——但「只调 effort 未调能力轴」
|
|
57
|
+
// 的派发需独立身份才能产出预算轴升建议,此时 effort 单独成键。
|
|
58
|
+
function cfgSet(cfg) {
|
|
59
|
+
return cfg !== null && typeof cfg === 'object' ? cfg : {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function profileKeyOf(cfg) {
|
|
63
|
+
const c = cfgSet(cfg);
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (c.preset !== undefined && c.preset !== null && c.preset !== 'inherit') parts.push(`preset:${c.preset}`);
|
|
66
|
+
if (c.provider !== undefined && c.provider !== null) parts.push(`provider:${c.provider}`);
|
|
67
|
+
if (c.model !== undefined && c.model !== null) parts.push(`model:${c.model}`);
|
|
68
|
+
if (c.persona_present === true) parts.push('persona:1');
|
|
69
|
+
if (c.toolFilter_present === true) parts.push('toolFilter:1');
|
|
70
|
+
if (parts.length === 0 && c.reasoningEffort !== undefined && c.reasoningEffort !== null) {
|
|
71
|
+
parts.push(`effort:${c.reasoningEffort}`);
|
|
72
|
+
}
|
|
73
|
+
return parts.length > 0 ? parts.join('|') : '(inline)';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 能力轴取值:preset/model/provider 取 effective 值(实数命中);persona 只存 present
|
|
77
|
+
// 布尔(隐私不落原文);toolFilter_from 取 toolFilter_present 的来源标记(requested/
|
|
78
|
+
// effective 在台账内同源,记 'requested'/'effective'/absent)。
|
|
79
|
+
function capabilityAxisKeys(profileKey, record) {
|
|
80
|
+
const eff = cfgSet(record?.effective);
|
|
81
|
+
const req = cfgSet(record?.requested);
|
|
82
|
+
const keys = [];
|
|
83
|
+
if (eff.preset !== undefined && eff.preset !== null) keys.push(`${profileKey}:preset:${eff.preset}`);
|
|
84
|
+
if (eff.model !== undefined && eff.model !== null) keys.push(`${profileKey}:model:${eff.model}`);
|
|
85
|
+
if (eff.provider !== undefined && eff.provider !== null) keys.push(`${profileKey}:provider:${eff.provider}`);
|
|
86
|
+
keys.push(`${profileKey}:persona:${eff.persona_present === true ? 'present' : 'absent'}`);
|
|
87
|
+
const from = req.toolFilter_present === true && eff.toolFilter_present === true
|
|
88
|
+
? 'requested-and-effective'
|
|
89
|
+
: (eff.toolFilter_present === true ? 'effective' : 'absent');
|
|
90
|
+
keys.push(`${profileKey}:toolFilter_from:${from}`);
|
|
91
|
+
return keys;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function budgetAxisKeys(profileKey, record) {
|
|
95
|
+
const eff = cfgSet(record?.effective);
|
|
96
|
+
const keys = [];
|
|
97
|
+
if (eff.maxTokens !== undefined && eff.maxTokens !== null) keys.push(`${profileKey}:maxTokens:${eff.maxTokens}`);
|
|
98
|
+
if (eff.reasoningEffort !== undefined && eff.reasoningEffort !== null) {
|
|
99
|
+
keys.push(`${profileKey}:reasoningEffort:${eff.reasoningEffort}`);
|
|
100
|
+
}
|
|
101
|
+
return keys;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- 单组聚合 -----------------------------------------------------------------
|
|
105
|
+
function outcomeCounts(records) {
|
|
106
|
+
const counts = { completed: 0, failed: 0, killed: 0 };
|
|
107
|
+
for (const r of records) {
|
|
108
|
+
if (r !== null && typeof r === 'object' && r.outcome && r.outcome.status === 'completed') counts.completed += 1;
|
|
109
|
+
else if (r !== null && typeof r === 'object' && r.outcome && r.outcome.status === 'failed') counts.failed += 1;
|
|
110
|
+
else if (r !== null && typeof r === 'object' && r.outcome && r.outcome.status === 'killed') counts.killed += 1;
|
|
111
|
+
}
|
|
112
|
+
return counts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function avg(existing, value) {
|
|
116
|
+
if (value === undefined || value === null || !Number.isFinite(value)) return existing;
|
|
117
|
+
if (existing === null) return { sum: value, count: 1 };
|
|
118
|
+
return { sum: existing.sum + value, count: existing.count + 1 };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function mean(acc) {
|
|
122
|
+
return acc === null || acc.count === 0 ? null : Number((acc.sum / acc.count).toFixed(2));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 组装一层聚合子集(L1 与 L2 复用):outcome/score/perf/confidence/cooldown/deployments_total。
|
|
126
|
+
function summarizeGroup(records, opts = {}) {
|
|
127
|
+
const counts = outcomeCounts(records);
|
|
128
|
+
const deploymentsTotal = counts.completed + counts.failed + counts.killed;
|
|
129
|
+
const parentAdoptedConfirmedFalse = opts.parentAdoptedConfirmedFalse ?? 0;
|
|
130
|
+
const score = {
|
|
131
|
+
weighted_success: weightedSuccess({ ...counts, parentAdoptedConfirmedFalse }),
|
|
132
|
+
win_rate: deploymentsTotal > 0 ? Number((counts.completed / deploymentsTotal).toFixed(3)) : 0,
|
|
133
|
+
};
|
|
134
|
+
let elapsedAcc = null;
|
|
135
|
+
let outputAcc = null;
|
|
136
|
+
let toolAcc = null;
|
|
137
|
+
for (const r of records) {
|
|
138
|
+
if (!r || typeof r !== 'object' || !r.outcome) continue;
|
|
139
|
+
elapsedAcc = avg(elapsedAcc, r.outcome.elapsed_ms);
|
|
140
|
+
outputAcc = avg(outputAcc, r.outcome.output_len);
|
|
141
|
+
// tool_count 只对 completed 结算(见台账),且无 usage 事件时空值不计。
|
|
142
|
+
if (r.outcome.status === 'completed') toolAcc = avg(toolAcc, r.outcome.tool_count);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
deployments_total: deploymentsTotal,
|
|
146
|
+
outcome: counts,
|
|
147
|
+
score,
|
|
148
|
+
perf: {
|
|
149
|
+
avg_elapsed_ms: mean(elapsedAcc),
|
|
150
|
+
avg_output_len: mean(outputAcc),
|
|
151
|
+
avg_tool_count: mean(toolAcc),
|
|
152
|
+
},
|
|
153
|
+
confidence: {
|
|
154
|
+
n: deploymentsTotal,
|
|
155
|
+
min_n_required: 3,
|
|
156
|
+
level: confidenceLevel(deploymentsTotal),
|
|
157
|
+
},
|
|
158
|
+
// 冷却期:until_ts/last_apply_ts 由应用层(建议采纳)回填;聚合层尚无来源,置 null。
|
|
159
|
+
cooldown: { until_ts: null, last_apply_ts: null },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- 顶层聚合(纯函数,无 IO)---------------------------------------------------
|
|
164
|
+
// records:dispatch.jsonl 逐行解析结果(调用方可先用 parseDispatchRecords 过滤损坏行)。
|
|
165
|
+
// opts.parentAdoptedConfirmedFalse:可选按聚合 key 的「已确认未采纳」计数 map,供外部
|
|
166
|
+
// 惰性 join 注入(Task 23 建议层);缺省视为 0(台账内无该信号)。返回 l1/l2 普通对象。
|
|
167
|
+
export function computeSummaries(records, opts = {}) {
|
|
168
|
+
const l1Map = new Map();
|
|
169
|
+
const l2Map = new Map();
|
|
170
|
+
for (const record of records) {
|
|
171
|
+
if (record === null || typeof record !== 'object') continue;
|
|
172
|
+
const eff = cfgSet(record.effective);
|
|
173
|
+
const profileKey = profileKeyOf(eff);
|
|
174
|
+
if (!l1Map.has(profileKey)) l1Map.set(profileKey, []);
|
|
175
|
+
l1Map.get(profileKey).push(record);
|
|
176
|
+
for (const axisKey of capabilityAxisKeys(profileKey, record).concat(budgetAxisKeys(profileKey, record))) {
|
|
177
|
+
if (!l2Map.has(axisKey)) l2Map.set(axisKey, []);
|
|
178
|
+
l2Map.get(axisKey).push(record);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const falseCounts = opts.parentAdoptedConfirmedFalse ?? {};
|
|
182
|
+
const build = (map) => {
|
|
183
|
+
const out = {};
|
|
184
|
+
for (const [key, list] of map) {
|
|
185
|
+
out[key] = summarizeGroup(list, { parentAdoptedConfirmedFalse: falseCounts[key] ?? 0 });
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
};
|
|
189
|
+
return { l1: build(l1Map), l2: build(l2Map) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- 只读建议(suggestAdvice)---------------------------------------------------
|
|
193
|
+
// 从单条 L1 聚合(l1Entry)产出「一项确定性建议」或 null。四条件齐备才产出:
|
|
194
|
+
// N>=minN、weighted_success 低于阈值(表现不佳才建议)、置信度达标、cooldown
|
|
195
|
+
// 未过期。方向按升/降轴硬规则:能力轴(preset/model/provider/persona/
|
|
196
|
+
// toolFilter)只建议降;预算轴(effort)可建议升;无法定位安全轴的键保守维持
|
|
197
|
+
// (平)。阈值 successThreshold / confidenceMinLevel / cooldownMs 为建议初值,
|
|
198
|
+
// 待实测后校准。
|
|
199
|
+
const CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
|
|
200
|
+
|
|
201
|
+
function hasCapabilityAxis(profileKey) {
|
|
202
|
+
return /(^|\|)(preset|provider|model|persona|toolFilter):/.test(String(profileKey ?? ''));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function hasBudgetAxis(profileKey) {
|
|
206
|
+
return /(^|\|)effort:/.test(String(profileKey ?? ''));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 从 profileKey 提取 preset 候选值(无 preset 段返回 null,如 inline / 纯预算键)。
|
|
210
|
+
export function presetFromKey(profileKey) {
|
|
211
|
+
if (typeof profileKey !== 'string' || profileKey === '' || profileKey === '(inline)') return null;
|
|
212
|
+
for (const part of profileKey.split('|')) {
|
|
213
|
+
if (part.startsWith('preset:')) return part.slice('preset:'.length);
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 建议候选校验:preset 候选非 system-trust 白名单即 fail-loud 抛错(与派发守卫
|
|
219
|
+
// whitelist 闸一致,不静默滤掉)。whitelist 接受 Set 或数组。
|
|
220
|
+
export function assertSystemCandidate(whitelist, preset) {
|
|
221
|
+
if (preset === null || preset === undefined || preset === '' || preset === 'inherit') return;
|
|
222
|
+
const inList = whitelist instanceof Set ? whitelist.has(preset) : Array.isArray(whitelist) && whitelist.includes(preset);
|
|
223
|
+
if (!inList) {
|
|
224
|
+
throw new Error(`evolution:advice: 候选预设 "${preset}" 不在 system-trust 白名单(拒绝非 system 候选)`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function performanceText(entry) {
|
|
229
|
+
const outcome = entry?.outcome ?? {};
|
|
230
|
+
const perf = entry?.perf ?? {};
|
|
231
|
+
const parts = [
|
|
232
|
+
`过去 ${entry?.deployments_total ?? 0} 次:完成 ${outcome.completed ?? 0}、失败 ${outcome.failed ?? 0}、终止 ${outcome.killed ?? 0}`,
|
|
233
|
+
`加权成功分 ${entry?.score?.weighted_success ?? 0}`,
|
|
234
|
+
];
|
|
235
|
+
if (perf.avg_elapsed_ms !== null && perf.avg_elapsed_ms !== undefined) parts.push(`平均耗时 ${perf.avg_elapsed_ms}ms`);
|
|
236
|
+
if (perf.avg_output_len !== null && perf.avg_output_len !== undefined) parts.push(`平均输出 ${perf.avg_output_len} 字符`);
|
|
237
|
+
return parts.join(',');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function directionFor(profileKey, capability, budget) {
|
|
241
|
+
if (budget && !capability) return '升';
|
|
242
|
+
if (capability) return '降';
|
|
243
|
+
return '平';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function suggestAdvice({
|
|
247
|
+
l1Entry,
|
|
248
|
+
profileKey = '',
|
|
249
|
+
minN = 3,
|
|
250
|
+
cooldownMs = 600000,
|
|
251
|
+
now = Date.now(),
|
|
252
|
+
successThreshold = 0.6,
|
|
253
|
+
confidenceMinLevel = 'medium',
|
|
254
|
+
} = {}) {
|
|
255
|
+
const entry = l1Entry !== null && typeof l1Entry === 'object' ? l1Entry : {};
|
|
256
|
+
const key = typeof profileKey === 'string' ? profileKey : (typeof entry.profileKey === 'string' ? entry.profileKey : '');
|
|
257
|
+
const n = Number.isFinite(entry.confidence?.n) ? entry.confidence.n : 0;
|
|
258
|
+
const level = typeof entry.confidence?.level === 'string' ? entry.confidence.level : confidenceLevel(n, minN);
|
|
259
|
+
const score = Number.isFinite(entry.score?.weighted_success) ? entry.score.weighted_success : 0;
|
|
260
|
+
const until = entry.cooldown?.until_ts;
|
|
261
|
+
|
|
262
|
+
// 四条件齐备才产出(任一不满足返回 null)。
|
|
263
|
+
if (n < minN) return null;
|
|
264
|
+
if ((CONFIDENCE_RANK[level] ?? -1) < (CONFIDENCE_RANK[confidenceMinLevel] ?? 0)) return null;
|
|
265
|
+
if (until !== null && until !== undefined && now < until) return null;
|
|
266
|
+
if (!(score < successThreshold)) return null;
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
profileKey: key,
|
|
270
|
+
performanceText: performanceText(entry),
|
|
271
|
+
suggestion: directionFor(key, hasCapabilityAxis(key), hasBudgetAxis(key)),
|
|
272
|
+
confidence: level,
|
|
273
|
+
cooldownUntil: now + cooldownMs,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --- 损坏行解析(纯函数,fail-soft 跳过 + 计数)-------------------------------
|
|
278
|
+
// 逐行 JSON.parse;损坏/空行跳过并计数,不影响有效记录。
|
|
279
|
+
export function parseDispatchRecords(lines) {
|
|
280
|
+
let skipped = 0;
|
|
281
|
+
const records = [];
|
|
282
|
+
for (const line of lines) {
|
|
283
|
+
if (typeof line !== 'string' || line.trim().length === 0) {
|
|
284
|
+
skipped += 1;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(line);
|
|
289
|
+
records.push(parsed);
|
|
290
|
+
} catch {
|
|
291
|
+
skipped += 1;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return { records, skipped };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// --- 原子写入(tmp+rename;profiles-store 模式)------------------------------
|
|
298
|
+
// 写 <file>.tmp 后 rename 覆盖,崩溃不产生截断文件。失败不 throw,返回 {persisted,
|
|
299
|
+
// error?} 由调用方按 fail-soft 处理。何时重算由调度层(审计分层/手动触发)决定,本
|
|
300
|
+
// 模块只提供写入原语。目录缺失时 mkdir recursive 补齐。
|
|
301
|
+
export function writeSummaries(file, data) {
|
|
302
|
+
const tmp = `${file}.tmp`;
|
|
303
|
+
try {
|
|
304
|
+
// 顶层带 v 版本号(台账按行带 v 同源),改 schema 时递增。
|
|
305
|
+
const payload = { v: 1, ...data, _computed_at: Date.now() };
|
|
306
|
+
const dir = dirname(file);
|
|
307
|
+
mkdirSync(dir, { recursive: true });
|
|
308
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
309
|
+
renameSync(tmp, file);
|
|
310
|
+
return { persisted: true };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
// 尽力清理未 rename 的残留 tmp。
|
|
313
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
314
|
+
return { persisted: false, error: error instanceof Error ? error.message : String(error) };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- 只读建议(evolution:advice 注入段,默认关)--------------------------------
|
|
319
|
+
|
|
320
|
+
// 读 summaries.json(存在才读;损坏/形状不符 → null,fail-soft)。
|
|
321
|
+
// §12.5 版本化:只接受 v:1;未知/超前版本 fail-soft 跳过(v1 无前驱,迁移表空)。
|
|
322
|
+
export function readSummaries(file, logger) {
|
|
323
|
+
try {
|
|
324
|
+
if (!existsSync(file)) return null;
|
|
325
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
326
|
+
if (parsed === null || typeof parsed !== 'object' || parsed.l1 === null || typeof parsed.l1 !== 'object') return null;
|
|
327
|
+
if (parsed.v !== 1) {
|
|
328
|
+
if (logger !== undefined && typeof logger.warn === 'function') {
|
|
329
|
+
logger.warn(`[dsh-subagent-profile] summaries.json 版本 ${String(parsed.v)} 未知,跳过该资产(fail-soft)`);
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
return parsed;
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 生产聚合触发点(T1 修复):读 dispatch.jsonl → parseDispatchRecords → computeSummaries
|
|
340
|
+
// → writeSummaries。此前只被测试调用,summaries.json 无初始生成路径。opts 可注入
|
|
341
|
+
// parentAdoptedConfirmedFalse join(F3 尚未接线)。缺失台账返回 skipped:'no-ledger'。
|
|
342
|
+
export function refreshSummaries({ dispatchFile, summariesFile, logger, opts = {} } = {}) {
|
|
343
|
+
if (!existsSync(dispatchFile)) return { persisted: false, skipped: 'no-ledger' };
|
|
344
|
+
let lines;
|
|
345
|
+
try {
|
|
346
|
+
lines = readFileSync(dispatchFile, 'utf8').split('\n');
|
|
347
|
+
} catch (error) {
|
|
348
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
349
|
+
if (logger !== undefined && typeof logger.warn === 'function') {
|
|
350
|
+
logger.warn(`[dsh-subagent-profile] evolution:advice 读派发台账失败:${message}`);
|
|
351
|
+
}
|
|
352
|
+
return { persisted: false, error: message };
|
|
353
|
+
}
|
|
354
|
+
const { records, skipped } = parseDispatchRecords(lines);
|
|
355
|
+
const summaries = computeSummaries(records, opts);
|
|
356
|
+
const res = writeSummaries(summariesFile, summaries);
|
|
357
|
+
return { ...res, records: records.length, skippedLines: skipped };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// summaries.json 是否过期(缺失但台账存在,或台账 mtime 更新)——惰性重算判据。
|
|
361
|
+
function summariesStale(dispatchFile, summariesFile) {
|
|
362
|
+
if (!existsSync(summariesFile)) return existsSync(dispatchFile);
|
|
363
|
+
try {
|
|
364
|
+
return statSync(dispatchFile).mtimeMs > statSync(summariesFile).mtimeMs;
|
|
365
|
+
} catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 注入段文本:全局聚合摘要 + 各 profile 一条确定性建议(人类读、非指令)。
|
|
371
|
+
export function renderAdviceText(advice, global) {
|
|
372
|
+
const direction = (value) => (value === '升' ? '可上调预算' : value === '降' ? '建议降级' : '维持现状');
|
|
373
|
+
const lines = advice.map((a) => {
|
|
374
|
+
const untilText = Number.isFinite(a.cooldownUntil)
|
|
375
|
+
? `(冷却至 ${new Date(a.cooldownUntil).toLocaleTimeString('zh-CN', { hour12: false })})`
|
|
376
|
+
: '';
|
|
377
|
+
return `- ${a.profileKey}:${a.performanceText};${direction(a.suggestion)}(置信度 ${a.confidence})${untilText}`;
|
|
378
|
+
});
|
|
379
|
+
const total = global.completed + global.failed + global.killed;
|
|
380
|
+
return `以下为过去派发的只读统计与确定性建议(仅供人类参考,不改变派发行为):\n` +
|
|
381
|
+
`全局:累计 ${total} 次(完成 ${global.completed}、失败 ${global.failed}、终止 ${global.killed})。\n${lines.join('\n')}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 建议产出后把各 profile 的新 cooldown.until_ts 写回 summaries.json(原子写、
|
|
385
|
+
// fail-soft)。写前重读磁盘再合并:并发注入各自基于最新磁盘态写回,把互相覆盖
|
|
386
|
+
// 窗口压到最小(残余竞态:读-写间隙,彻底解决需进程内锁,成本不值)。
|
|
387
|
+
function writeBackCooldowns(file, data, produced, logger) {
|
|
388
|
+
const fresh = readSummaries(file, logger);
|
|
389
|
+
const base = fresh !== null && typeof fresh === 'object' ? fresh : data;
|
|
390
|
+
const l1 = { ...(base.l1 ?? {}) };
|
|
391
|
+
for (const [profileKey, until] of produced) {
|
|
392
|
+
const group = l1[profileKey];
|
|
393
|
+
l1[profileKey] = { ...group, cooldown: { ...(group?.cooldown ?? {}), until_ts: until } };
|
|
394
|
+
}
|
|
395
|
+
const res = writeSummaries(file, { l1, l2: base.l2 ?? {} });
|
|
396
|
+
if (res.persisted !== true) {
|
|
397
|
+
logger.warn(`[dsh-subagent-profile] evolution:advice cooldown 写回失败:${res.error ?? '未知错误'}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 只读建议段文本生成:读 summaries → 逐 profile 校验候选(非 system fail-loud)→
|
|
402
|
+
// suggestAdvice → 写回 cooldown → 渲染。空组跳过候选校验(只对产出建议要求 trust)。
|
|
403
|
+
export function buildAdviceText({ summariesFile, dispatchFile, whitelist, logger }) {
|
|
404
|
+
// T1 惰性重算:summaries.json 缺失或台账已更新(mtime)时先 refreshSummaries。
|
|
405
|
+
if (dispatchFile !== undefined && summariesStale(dispatchFile, summariesFile)) {
|
|
406
|
+
refreshSummaries({ dispatchFile, summariesFile, logger });
|
|
407
|
+
}
|
|
408
|
+
const data = readSummaries(summariesFile, logger);
|
|
409
|
+
if (data === null) return '';
|
|
410
|
+
const l1 = data.l1 ?? {};
|
|
411
|
+
const keys = Object.keys(l1).sort();
|
|
412
|
+
const advice = [];
|
|
413
|
+
const produced = [];
|
|
414
|
+
const global = { completed: 0, failed: 0, killed: 0 };
|
|
415
|
+
for (const profileKey of keys) {
|
|
416
|
+
const group = l1[profileKey];
|
|
417
|
+
if (group === null || typeof group !== 'object') continue;
|
|
418
|
+
global.completed += group.outcome?.completed ?? 0;
|
|
419
|
+
global.failed += group.outcome?.failed ?? 0;
|
|
420
|
+
global.killed += group.outcome?.killed ?? 0;
|
|
421
|
+
const suggestion = suggestAdvice({ l1Entry: group, profileKey });
|
|
422
|
+
if (suggestion === null) continue;
|
|
423
|
+
// F4:候选校验移到「实际产出建议」之后——N<3 组不产出建议,不应因非 system
|
|
424
|
+
// 候选而抛(校验先于判定与模块注释「只有实际产出的建议键才要求 system-trust」矛盾)。
|
|
425
|
+
assertSystemCandidate(whitelist, presetFromKey(profileKey));
|
|
426
|
+
advice.push(suggestion);
|
|
427
|
+
produced.push([profileKey, suggestion.cooldownUntil]);
|
|
428
|
+
}
|
|
429
|
+
if (advice.length === 0) return '';
|
|
430
|
+
writeBackCooldowns(summariesFile, data, produced, logger);
|
|
431
|
+
return renderAdviceText(advice, global);
|
|
432
|
+
}
|