dsh-subagent-profile 0.3.3 → 0.3.4

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.
@@ -5,33 +5,31 @@
5
5
  // 注入:所有 apply 闭包 / ctx 依赖都是显式参数——
6
6
  // register ctx.tools.register(工具由 syncTool 注册/注销,设置开关可运行时移除),
7
7
  // store profile store(resolveProfile 取基方案、getAllowFailOpen 供 cost guard),
8
- // getEnabled 读取 apply 闭包 `enabled` 标志(continuable fail-loud 门 +
9
- // syncTool 注册条件),
8
+ // getEnabled 读取 apply 闭包 `enabled` 标志(continuable fail-loud 门 + syncTool 注册条件),
10
9
  // getService 请求时服务 getter(ctx.get('toolResultPruner') / ctx.get('jobs')
11
10
  // 按次读取,绝不在 apply 时读),
12
11
  // logger ctx.logger(决策级派发日志 + continuable 告警),
13
12
  // subagents ctx.subagents(start / startContinuable 驱动子 Agent),
14
- // guard dispatch guard(execute 入口 acquire / 结算 release+记账 /
15
- // syncTool 禁用时 cancelAll+reset)。
16
- // 工厂返回 { syncTool, dispose }:syncTool 交给 HTTP 路由(/set-enabled),
17
- // dispose 在插件卸载时运行。
13
+ // guard dispatch guard(execute 入口 acquire / 结算 release+记账 / syncTool 禁用时 cancelAll+reset)。
14
+ // 工厂返回 { syncTool, dispose }:syncTool 交给 HTTP 路由(/set-enabled),dispose 在插件卸载时运行。
18
15
  //
19
- // execute 按预检段 + 三分支拆为模块级私有函数;parameters / output schema 拆至
20
- // dispatch-schema.mjs,预检闸应用拆至 dispatch-gates.mjs(守文件行门),零漂移。
16
+ // execute 按预检段 + 三分支拆为模块级私有函数;parameters/output schema 拆至 dispatch-schema.mjs,预检闸拆至 dispatch-gates.mjs(守文件行门),零漂移。
21
17
 
22
18
  import { defineTool } from './shims.mjs';
23
- import { textFrom, stopReasonError, withPartialText, pruneBlocks, assertResultSchemaConsistency, trustLabel, trustAudit, dispatchLabel, tierSortKey } from './pure.mjs';
19
+ import { textFrom, stopReasonError, withPartialText, pruneBlocks, assertResultSchemaConsistency, trustLabel, trustAudit, dispatchLabel } from './pure.mjs';
24
20
  import { resolveWhitelist } from './whitelist.mjs';
25
21
  import { recordEscapeAllow } from './escape.mjs';
26
22
  import { settleStart, collectChildUsage, collectChildCalls } from './delegation.mjs';
27
23
  import { DISPATCH_OUTPUT_SCHEMA, DISPATCH_PARAMETERS, DISPATCH_RENDER } from './dispatch-schema.mjs';
28
24
  import { createDecisionTrace, finalizeTrace, assertTraceSize, parentContextOf, requestedOf, effectiveMeta, recordApprovalGate, recordFailure, profileSnapshotOf } from './decision-trace.mjs';
25
+ import { profileDirectoryRows } from './profile-directory.mjs';
29
26
  import { applyWhitelistGate, applyCostGuardGate, applyIntersectionGate, assertContinuableEnabled, readParentToolNames, applyBudgetGate } from './dispatch-gates.mjs';
30
27
  import { trackForegroundInflight, trackBackgroundInflight, trackContinuableInflight } from './dispatch-guard.mjs';
31
28
  import { finishForegroundLedger, finishBackgroundLedger, finishContinuableLedger } from './evolution-ledger.mjs';
29
+ import { profileKeyOf } from './evolution-summary.mjs';
30
+ import { promptExcerptOf } from './adoption-reminder.mjs';
32
31
 
33
- // 信封骨架:envelope:true 时追加进子 persona 的结构化汇报契约(三分支共享);骨架
34
- // 只进 persona(systemPrompt 影子段、模型可见),不进 descriptor(model-hidden,仅 session 记录)。
32
+ // 信封骨架:envelope:true 时追加进子 persona 的结构化汇报契约(三分支共享);只进 persona(模型可见),不进 descriptor(仅 session 记录)。
35
33
  const ENVELOPE_SKELETON = '完成前按以下骨架输出:## 结论(1-2 句)\n## 结构分项(逐项)\n## 关键发现落点(文件路径/数据位置)';
36
34
 
37
35
  // --- execute 预检段(从 execute 拆出;行为逐字不变)-----------------------------
@@ -126,7 +124,7 @@ async function runContinuable(args, merged, meta, parent, exec, deps, trace, eff
126
124
  if (args.run_in_background === true) {
127
125
  deps.logger.warn('[dsh-subagent-profile] dispatch: both continuable and run_in_background are true; continuable takes precedence');
128
126
  }
129
- // 已知降级:continuable 标准路径不支持 preset swap 和 reasoningEffort(subagent 包的 SubagentStartRequest 无 preset 字段、AgentOptions 无 reasoningEffort 字段)
127
+ // 已知降级:continuable 标准路径不支持 preset swap 和 reasoningEffort(宿主请求面无对应字段)。
130
128
  if (merged.preset !== undefined && merged.preset !== 'inherit') {
131
129
  deps.logger.warn(`[dsh-subagent-profile] continuable mode cannot swap preset; ignoring "${merged.preset}" (child inherits the parent preset)`);
132
130
  }
@@ -141,10 +139,10 @@ async function runContinuable(args, merged, meta, parent, exec, deps, trace, eff
141
139
  signal: exec.signal
142
140
  });
143
141
  trackContinuableInflight(deps.guard, deps.subagents, childId, parent.session?.header?.id);
144
- // Continuable 丢弃方案的 preset swap reasoningEffort(子继承父预设),
145
- // 故可观测 meta 必须报告实际生效值,而非被忽略的请求值。
146
- // 可见性修复:`reasoningEffort` 回显**请求值**(经 meta.reasoningEffort),
147
- // `preset:'inherit'` 是真实生效值;`ignored` 明确列出被丢弃项。
142
+ deps.adoptionTracker?.register({ parentSessionId: parent.session?.header?.id, id: childId, ledgerKey: profileKeyOf(base.effective), mode: 'continuable', dispatchedAt: Date.now(), promptExcerpt: promptExcerptOf(args?.prompt), requestedModel: merged.model, parentModel: parent.options?.model });
143
+ // Continuable 丢弃方案的 preset swap 与 reasoningEffort(子继承父预设),故可观测
144
+ // meta 必须报告实际生效值:reasoningEffort 回显请求值,preset:'inherit' 是生效值,
145
+ // ignored 明确列出被丢弃项。
148
146
  // 信任标注不适用:continuable 只回 subagentId、无文本 output(后续经宿主的
149
147
  // send_message 流转),没有可加前缀的回收文本,故此处不加 trustLabel。
150
148
  const out = {
@@ -171,7 +169,7 @@ async function runContinuable(args, merged, meta, parent, exec, deps, trace, eff
171
169
 
172
170
  // 后台 one-shot(job)路径:jobs.start 包 start() 并提供原生
173
171
  // AbortController;仍是一轮即弃,非 continuable。
174
- async function runBackground(args, merged, meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base) {
172
+ async function runBackground(args, merged, meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base, exec) {
175
173
  const jobs = deps.getService('jobs');
176
174
  if (jobs === undefined) {
177
175
  throw new Error('dispatch: 后台派发不可用:缺少 jobs 服务(请安装 @deepseek-ai/dsh-jobs 与 @deepseek-ai/dsh-tool-jobs,或改用前台派发)');
@@ -181,6 +179,9 @@ async function runBackground(args, merged, meta, request, parent, deps, pruneRes
181
179
  const onSettled = (settled) => {
182
180
  guardHandle.finish(jobId, settled.childTotalTokens);
183
181
  finishBackgroundLedger(deps.evoLedger, base, jobId, settled);
182
+ if (deps.backgroundLedger !== undefined) deps.backgroundLedger.record(base.session_id, jobId, settled, exec?.callId);
183
+ deps.adoptionTracker?.updateOutput(base.session_id, jobId, typeof settled.output === 'string' ? settled.output : '');
184
+ deps.adoptionTracker?.updateSettled?.(base.session_id, jobId, settled);
184
185
  };
185
186
  jobId = jobs.start({
186
187
  kind: 'subagent',
@@ -188,10 +189,12 @@ async function runBackground(args, merged, meta, request, parent, deps, pruneRes
188
189
  owner: parent,
189
190
  run: () => {
190
191
  const controller = new AbortController();
192
+ const startPromise = deps.subagents.start('profile', { ...request, signal: controller.signal });
193
+ startPromise.then((run) => { if (run !== null && run !== undefined && typeof run.id === 'string') deps.backgroundLedger?.recordChild?.(base.session_id, jobId, run.id); }).catch(() => {}); // 结算前补记 jobId → childSessionId,供页头识别后台派发。
191
194
  return {
192
195
  cancel: (reason) => controller.abort(reason ?? 'dispatch: background subagent task killed'),
193
196
  done: settleStart(
194
- deps.subagents.start('profile', { ...request, signal: controller.signal }),
197
+ startPromise,
195
198
  controller.signal,
196
199
  meta,
197
200
  pruneResultOutput,
@@ -204,15 +207,15 @@ async function runBackground(args, merged, meta, request, parent, deps, pruneRes
204
207
  }
205
208
  });
206
209
  trackBackgroundInflight(deps.guard, jobs, jobId, parent);
207
- // 后台:execute 返回时 start 尚未 resolve,无 settled(结算经 job 结果携带,不入会话块)。
210
+ deps.adoptionTracker?.register({ parentSessionId: parent.session?.header?.id, id: jobId, ledgerKey: profileKeyOf(base.effective), mode: 'background', dispatchedAt: t0, promptExcerpt: promptExcerptOf(args?.prompt), requestedModel: merged.model, parentModel: parent.options?.model }); // 后台结算经 job 结果携带,不入会话块。
208
211
  const out = { kind: 'background', jobId, ...meta };
209
212
  finalizeTrace(trace, { effective: effectiveMeta(meta, []), execution: { kind: 'background', parentSessionId: parent.session?.header?.id, jobId, mode: 'one-shot' } });
210
213
  out.decisionTrace = assertTraceSize(trace);
211
214
  return out;
212
215
  }
213
216
 
214
- // childTotalTokens:宿主 tokenMeter 启发式估算(surface 口径,非计费 usage)。
215
- // completed 结算时测量;服务缺失/非函数/抛错均返回 undefined(fail-soft)。
217
+ // childTotalTokens:宿主 tokenMeter 启发式估算(surface 口径,非计费 usage);仅
218
+ // completed 结算时测量,服务缺失/非函数/抛错均返回 undefined(fail-soft)。
216
219
  function measureChildTokens(getService, session) {
217
220
  if (session === undefined || session === null) return undefined;
218
221
  const meter = getService('tokenMeter');
@@ -239,13 +242,13 @@ function measureForegroundRun(deps, run) {
239
242
  }
240
243
 
241
244
  // 前台:收集结果,无论 reject 与否都释放句柄,非 completed 停因 fail-loud;
242
- // run.result reject(undefined)也写失败 outcome(M9)。
243
- async function runForeground(meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base) {
245
+ // run.result reject(undefined)也写失败 outcome
246
+ async function runForeground(meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base, adoptionBase) {
244
247
  const run = await deps.subagents.start('profile', request);
245
248
  finalizeTrace(trace, { execution: { kind: 'foreground', parentSessionId: parent.session?.header?.id, childSessionId: run.id, mode: 'one-shot' } });
246
249
  trackForegroundInflight(deps.guard, run);
247
- let result;
248
- let measurement;
250
+ deps.adoptionTracker?.register({ parentSessionId: parent.session?.header?.id, id: run.id, ledgerKey: profileKeyOf(base.effective), mode: 'foreground', dispatchedAt: t0, ...adoptionBase }); // 前台派发登记 parent_adopted 待判定。
251
+ let result, measurement;
249
252
  try {
250
253
  result = await run.result;
251
254
  if (result.stopReason === 'completed') measurement = measureForegroundRun(deps, run);
@@ -254,13 +257,13 @@ async function runForeground(meta, request, parent, deps, pruneResultOutput, t0,
254
257
  await run.dispose().catch(() => {});
255
258
  guardHandle.finish(run.id, measurement?.childTotalTokens); // untrack + recordTokens + release
256
259
  }
257
- const childTotalTokens = measurement?.childTotalTokens;
258
- const childUsage = measurement?.childUsage;
259
- const childCalls = measurement?.childCalls;
260
+ const { childTotalTokens, childUsage, childCalls } = measurement ?? {};
260
261
  // 台账:completed 与非 completed 都写 outcome(对照 settleStart 语义),随后非 completed 仍 throw。
261
262
  const pruned = pruneResultOutput(result.output);
262
263
  const elapsedMs = Date.now() - t0;
263
- finishForegroundLedger(deps.evoLedger, base, run.id, result, pruned, elapsedMs, childCalls);
264
+ finishForegroundLedger(deps.evoLedger, base, run.id, result, pruned, elapsedMs, childCalls, childTotalTokens, childUsage);
265
+ deps.adoptionTracker?.updateOutput(parent.session?.header?.id, run.id, textFrom(pruned));
266
+ deps.adoptionTracker?.updateSettled?.(parent.session?.header?.id, run.id, { stopReason: result.stopReason, elapsedMs, ...(childTotalTokens !== undefined ? { childTotalTokens } : {}), ...(childUsage !== undefined ? { childUsage } : {}), childSessionId: run.id }); // 结算摘要回填供未采纳提醒结果行,已拒/未完成同样登记。
264
267
  const failure = stopReasonError(result);
265
268
  if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
266
269
  const metaOut = {
@@ -281,8 +284,7 @@ async function runForeground(meta, request, parent, deps, pruneResultOutput, t0,
281
284
  },
282
285
  });
283
286
  // 信任标注:completed 子结果回灌父上下文前加结构化前缀(profile/preset 元数据、
284
- // 无 prompt 原文),审计同字段 JSON 只进 logger、不进结果字符串。render 行首部
285
- // 格式不变——前缀只进 output 文本(模型看到的正文带头)。
287
+ // 无 prompt 原文),审计同字段 JSON 只进 logger、不进结果字符串;前缀只进 output 文本。
286
288
  const output = `${trustLabel(metaOut)}${textFrom(pruned)}`;
287
289
  deps.logger.info('[dsh-subagent-profile] trusted-output: ' + trustAudit(metaOut));
288
290
  return { output, ...metaOut, decisionTrace: assertTraceSize(trace) };
@@ -292,13 +294,17 @@ async function runForeground(meta, request, parent, deps, pruneResultOutput, t0,
292
294
  // 与轨迹(避免重复 list() / schemas()),并组装 trace 骨架与交集闸 input。
293
295
  async function prepareTrace(args, parent, deps) {
294
296
  const agentPresets = parent.ctx.get('agentPresets');
295
- const whitelist = new Set(await resolveWhitelist(agentPresets, deps.getEscapeSet()));
297
+ const baseWhitelist = new Set(await resolveWhitelist(agentPresets));
298
+ const escapeSet = new Set(deps.getEscapeSet());
299
+ const whitelist = new Set([...baseWhitelist, ...escapeSet]);
296
300
  const parentComposed = agentPresets !== undefined ? agentPresets.composedPreset(parent.ctx) : undefined;
297
301
  const parentToolNames = readParentToolNames(parent);
298
302
  const parentToolCount = parentToolNames.length;
299
- // 决策输入快照:与 dispatch:profiles section 同源同序(enabled、cheap-first)——
300
- // 「模型当时看到哪些候选」是回答「为什么这么选」的记录面(P5 补偿)。
301
- const snapshot = profileSnapshotOf([...deps.store.profiles.values()].filter((p) => p.enabled !== false).sort((a, b) => tierSortKey(a.tokenTier) - tierSortKey(b.tokenTier)));
303
+ // 决策输入快照:与 dispatch:profiles section 同源同序(profileDirectoryRows,
304
+ // enabled、cheap-first)——「模型当时看到哪些候选」是回答「为什么这么选」的记录面。
305
+ const rows = profileDirectoryRows(deps.store);
306
+ const snapshot = profileSnapshotOf(rows, { chosenId: args.profile });
307
+ const advicePresent = typeof deps.getEvolutionAdvice === 'function' && deps.getEvolutionAdvice() === true && parentComposed === 'orchestrator';
302
308
  const trace = createDecisionTrace(
303
309
  parentContextOf({
304
310
  parentPreset: parentComposed,
@@ -307,11 +313,11 @@ async function prepareTrace(args, parent, deps) {
307
313
  parentToolCount,
308
314
  allowFailOpen: deps.store.getAllowFailOpen(),
309
315
  }),
310
- requestedOf(args, { profiles: snapshot.entries })
316
+ requestedOf(args, { profiles: snapshot.entries, advicePresent })
311
317
  );
312
318
  const mode = args.continuable === true ? 'continuable' : (args.run_in_background === true ? 'background' : 'foreground');
313
319
  const intersectionInput = { parentToolCount, requestedToolFilter: args.toolFilter, mode };
314
- return { whitelist, parentComposed, parentToolNames, trace, mode, intersectionInput };
320
+ return { whitelist, baseWhitelist, escapeSet, parentComposed, parentToolNames, trace, mode, intersectionInput };
315
321
  }
316
322
 
317
323
  // execute 主体:预检段 + 三分支分派。任一闸 fail 先记 fail 闸再 rethrow 原错误
@@ -321,7 +327,7 @@ async function runDispatch(args, exec, deps) {
321
327
  if (!parent) throw new Error('dispatch: 缺少调用 Agent(值需来自模型/编排者会话,请勿直接调用本工具)');
322
328
  // 派发耗时基准:execute 入口记 t0,前台/后台各自在结算处算 elapsedMs。
323
329
  const t0 = Date.now();
324
- const { whitelist, parentComposed, parentToolNames, trace, mode, intersectionInput } = await prepareTrace(args, parent, deps);
330
+ const { whitelist, baseWhitelist, escapeSet, parentComposed, parentToolNames, trace, mode, intersectionInput } = await prepareTrace(args, parent, deps);
325
331
  // 预算闸句柄提至 try 外:任何分支 throw(前台 start reject / 后台 jobs 缺失等)
326
332
  // 都在 catch 里幂等 release 并发槽——防错误路径把父会话的并发额度永久耗尽
327
333
  // (正常路径 finish 已 release,catch 再调为 no-op)。
@@ -335,7 +341,7 @@ async function runDispatch(args, exec, deps) {
335
341
  merged.persona = hasPersona ? `${merged.persona}\n\n${ENVELOPE_SKELETON}` : ENVELOPE_SKELETON;
336
342
  }
337
343
  assertContinuableEnabled(deps, args);
338
- applyWhitelistGate(trace, merged, whitelist, parentComposed);
344
+ applyWhitelistGate(trace, merged, whitelist, parentComposed, escapeSet, baseWhitelist);
339
345
  // Cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控)。
340
346
  await applyCostGuardGate(trace, merged, parent, deps);
341
347
  // 交集闸(三分支统一闸序,见 dispatch-gates.mjs);continuable 的 effectiveAllow
@@ -344,6 +350,7 @@ async function runDispatch(args, exec, deps) {
344
350
  recordApprovalGate(trace);
345
351
  const meta = buildMeta(args, merged, parent);
346
352
  const request = buildRequest(args, merged, parent, exec.signal);
353
+ const adoptionBase = { promptExcerpt: promptExcerptOf(args?.prompt), requestedModel: merged.model, parentModel: parent.options?.model };
347
354
  // 结果回收默认剪枝:在 textFrom 前复用宿主 toolResultPruner.pruneContent 预剪;
348
355
  // pruner 缺失时 pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。
349
356
  const pruneResultOutput = (blocks) => pruneBlocks(blocks, deps.getService('toolResultPruner'));
@@ -355,8 +362,8 @@ async function runDispatch(args, exec, deps) {
355
362
  const base = deps.evoLedger.baseEntry({ parent, args, merged, mode });
356
363
  recordEscapeAllow(deps, parent, merged, trace);
357
364
  if (args.continuable === true) return await runContinuable(args, merged, meta, parent, exec, deps, trace, effectiveAllow, base);
358
- if (args.run_in_background === true) return await runBackground(args, merged, meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base);
359
- return await runForeground(meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base);
365
+ if (args.run_in_background === true) return await runBackground(args, merged, meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base, exec);
366
+ return await runForeground(meta, request, parent, deps, pruneResultOutput, t0, trace, guardHandle, base, adoptionBase);
360
367
  } catch (error) {
361
368
  guardHandle.release();
362
369
  recordFailure(deps.ledger, parent, trace);
@@ -364,16 +371,16 @@ async function runDispatch(args, exec, deps) {
364
371
  }
365
372
  }
366
373
 
367
- export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, getEscapeSet }) {
368
- const deps = { store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, getEscapeSet };
374
+ export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, backgroundLedger, adoptionTracker, getEscapeSet, getEvolutionAdvice }) {
375
+ const deps = { store, getEnabled, getService, logger, subagents, catalog, ledger, guard, evoLedger, backgroundLedger, adoptionTracker, getEscapeSet, getEvolutionAdvice };
369
376
  const dispatchTool = defineTool({
370
377
  name: 'dispatch',
371
- description: '派发子任务给派生子 Agent,可逐个覆盖其 preset、model、provider、推理档位、persona、工具白名单、token 预算或递归深度。前台等待结果;run_in_background: true 启动后台任务(单轮即弃);continuable: true 启动持久子 Agent,后续轮次经 send_message 工具延续对话。前瞻:continuable 模式忽略 preset 换用与 reasoningEffort(结果以 ignored 提示)。',
378
+ description: '派发子任务给派生子 Agent,可逐个覆盖其 preset、model、provider、推理档位、persona、工具白名单、token 预算或递归深度。优先使用已保存的 profile,仅少数字段需要临时覆盖时才传 per-call 参数;未指定 model 时子代理继承父模型,查资料/汇总/读文件等轻任务请优先考虑 cheap 方案或显式 model=flash。前台等待结果;run_in_background: true 启动后台任务(单轮即弃);continuable: true 启动持久子 Agent,后续轮次经 send_message 工具延续对话。前瞻:continuable 模式忽略 preset 换用与 reasoningEffort(结果以 ignored 提示)。',
372
379
  parameters: DISPATCH_PARAMETERS,
373
380
  output: {
374
381
  schema: DISPATCH_OUTPUT_SCHEMA,
375
382
  render: DISPATCH_RENDER,
376
- // 决策轨迹经 presentationMeta 投影进会话块 meta(客户端账本数据源),不进 render 行。
383
+ // 决策轨迹经 presentationMeta 投影进会话块 meta(客户端台账数据源),不进 render 行。
377
384
  presentationMeta: (_args, value) => value.decisionTrace,
378
385
  },
379
386
  isConcurrencySafe: () => true,
@@ -0,0 +1,45 @@
1
+ // lib/core/draft-gates.mjs — auto-profile S1 draft 落库前的闸评估。
2
+ // 与派发守卫闸同源(形状/白名单/目录),但不落库、不 throw——只产出结构化 checks
3
+ // 供 /draft/preview 与应用复用。依赖注入:ctx / catalog 由调用方传入;store 只读
4
+ // profiles Map 判断 id 冲突。
5
+
6
+ import { sanitizeProfile } from './pure.mjs';
7
+ import { resolveWhitelist } from './whitelist.mjs';
8
+
9
+ // 评估一个 draft:sanitize(形状/硬上限)→ id 冲突 → 白名单 → 目录。checks 每项
10
+ // 为 { name, verdict: 'pass'|'fail', reason? };ok = 全部 pass。任一闸 fail 时 clean
11
+ // 仍返回(sanitize 归一后的形态),调用方可用其展示「将被写入的生效值」。
12
+ export async function assessDraftProfile({ ctx, store, catalog, getEscapeSet, draft }) {
13
+ if (draft === null || typeof draft !== 'object') {
14
+ return { ok: false, checks: [{ name: 'draft', verdict: 'fail', reason: 'draft 不存在' }], clean: null };
15
+ }
16
+ const config = draft.config !== null && typeof draft.config === 'object' ? draft.config : {};
17
+ const profile = { ...config, name: draft.name, description: draft.description };
18
+ const { clean, warnings } = sanitizeProfile(profile, { strict: true });
19
+ const checks = [];
20
+ checks.push(warnings.length > 0
21
+ ? { name: 'sanitize', verdict: 'fail', reason: warnings.map((w) => w.field + ':' + w.reason).join(';') }
22
+ : { name: 'sanitize', verdict: 'pass' });
23
+ if (typeof clean.id !== 'string' || clean.id === '') {
24
+ checks.push({ name: 'id', verdict: 'fail', reason: 'draft 缺少 profile id' });
25
+ } else if (store.profiles.has(clean.id)) {
26
+ checks.push({ name: 'id', verdict: 'fail', reason: 'profile ' + clean.id + ' 已存在(请先删除或改名)' });
27
+ } else {
28
+ checks.push({ name: 'id', verdict: 'pass' });
29
+ }
30
+ const whitelist = new Set(await resolveWhitelist(ctx.get('agentPresets'), getEscapeSet()));
31
+ if (typeof clean.preset === 'string' && clean.preset !== '' && clean.preset !== 'inherit' && !whitelist.has(clean.preset)) {
32
+ checks.push({ name: 'whitelist', verdict: 'fail', reason: '目标预设 ' + clean.preset + ' 不在 system-trust 白名单(可开启逃生舱并添加该预设后重试)' });
33
+ } else {
34
+ checks.push({ name: 'whitelist', verdict: 'pass' });
35
+ }
36
+ const snapshot = await catalog.getSnapshot();
37
+ if (typeof clean.model === 'string' && clean.model !== '' && !snapshot.models.some((m) => m && m.id === clean.model)) {
38
+ checks.push({ name: 'catalog', verdict: 'fail', reason: '模型 ' + clean.model + ' 不在当前模型目录' });
39
+ } else if (typeof clean.provider === 'string' && clean.provider !== '' && !snapshot.models.some((m) => m && m.provider === clean.provider)) {
40
+ checks.push({ name: 'catalog', verdict: 'fail', reason: '提供方 ' + clean.provider + ' 不在当前模型目录' });
41
+ } else {
42
+ checks.push({ name: 'catalog', verdict: 'pass' });
43
+ }
44
+ return { ok: checks.every((c) => c.verdict === 'pass'), checks, clean };
45
+ }
@@ -0,0 +1,45 @@
1
+ // lib/core/drafts-store.mjs — auto-profile draft 容器。
2
+ // 与生产注册表 subagent-profiles.json 隔离:draft 只写 subagent-evolution/drafts.json,
3
+ // 未经人确认并过闸不得进入 profiles Map。原子写(tmp+rename),损坏 fail-soft 从空。
4
+
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ function loadDrafts(file) {
9
+ if (!existsSync(file)) return [];
10
+ try {
11
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
12
+ if (parsed !== null && typeof parsed === 'object' && Array.isArray(parsed.drafts)) {
13
+ return parsed.drafts.filter((d) => d !== null && typeof d === 'object' && typeof d.id === 'string');
14
+ }
15
+ } catch { /* fail-soft 从空 */ }
16
+ return [];
17
+ }
18
+
19
+ function createDraftsStore({ dshHome, onGovernanceFailure = () => {} } = {}) {
20
+ const dir = join(dshHome, 'subagent-evolution');
21
+ const file = join(dir, 'drafts.json');
22
+ const drafts = loadDrafts(file);
23
+ const persist = () => {
24
+ const tmp = file + '.tmp';
25
+ try {
26
+ mkdirSync(dir, { recursive: true });
27
+ writeFileSync(tmp, JSON.stringify({ v: 1, drafts }, null, 2), 'utf8');
28
+ renameSync(tmp, file);
29
+ return { persisted: true };
30
+ } catch (error) {
31
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
32
+ onGovernanceFailure();
33
+ return { persisted: false, error: error instanceof Error ? error.message : String(error) };
34
+ }
35
+ };
36
+ return {
37
+ list: () => drafts.map((d) => ({ ...d })),
38
+ get: (id) => { const d = drafts.find((x) => x.id === id); return d === undefined ? undefined : { ...d }; },
39
+ add: (draft) => { drafts.push(draft); return persist(); },
40
+ remove: (id) => { const i = drafts.findIndex((x) => x.id === id); if (i < 0) return { persisted: true, removed: false }; drafts.splice(i, 1); return { ...persist(), removed: true }; },
41
+ clear: () => { drafts.length = 0; },
42
+ };
43
+ }
44
+
45
+ export { createDraftsStore };
@@ -0,0 +1,224 @@
1
+ // lib/core/evolution-advice.mjs — evolution:advice 派发优化建议链(从 evolution-summary.mjs
2
+ // 拆出以守文件行门):suggestAdvice(纯函数四条件 + 升/降轴硬规则)、readSummaries /
3
+ // refreshSummaries / computeAdvice / buildAdviceText(生产聚合触发与注入文本)。
4
+ // 聚合本体(computeSummaries/writeSummaries)仍驻 evolution-summary.mjs;本模块只读
5
+ // 台账并调度聚合,绝无写回台账路径。fail-soft 纪律与 evolution-summary 同源。
6
+ //
7
+ // 依赖方向:本模块 → evolution-summary.mjs(单向,无环)。
8
+
9
+ import { existsSync, readFileSync, statSync } from 'node:fs';
10
+ import { computeSummaries, parseDispatchRecords, writeSummaries, confidenceLevel } from './evolution-summary.mjs';
11
+
12
+ // --- 派发优化建议(suggestAdvice)-----------------------------------------------
13
+ // 从单条 L1 聚合(l1Entry)产出「一项确定性建议」或 null。四条件齐备才产出:
14
+ // N>=minN、weighted_success 低于阈值(表现不佳才建议)、置信度达标、cooldown
15
+ // 未过期。方向按升/降轴硬规则:能力轴(preset/model/provider/persona/
16
+ // toolFilter)只建议降;预算轴(effort)可建议升;无法定位安全轴的键保守维持
17
+ // (平)。阈值 successThreshold / confidenceMinLevel / cooldownMs 为建议初值,
18
+ // 待实测后校准。
19
+ const CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
20
+
21
+ function hasCapabilityAxis(profileKey) {
22
+ return /(^|\|)(preset|provider|model|persona|toolFilter):/.test(String(profileKey ?? ''));
23
+ }
24
+
25
+ function hasBudgetAxis(profileKey) {
26
+ return /(^|\|)effort:/.test(String(profileKey ?? ''));
27
+ }
28
+
29
+ // 从 profileKey 提取 preset 候选值(无 preset 段返回 null,如 inline / 纯预算键)。
30
+ export function presetFromKey(profileKey) {
31
+ if (typeof profileKey !== 'string' || profileKey === '' || profileKey === '(inline)') return null;
32
+ for (const part of profileKey.split('|')) {
33
+ if (part.startsWith('preset:')) return part.slice('preset:'.length);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ // 建议候选校验:preset 候选非 system-trust 白名单即 fail-loud 抛错(与派发守卫
39
+ // whitelist 闸一致,不静默滤掉)。whitelist 接受 Set 或数组。
40
+ export function assertSystemCandidate(whitelist, preset) {
41
+ if (preset === null || preset === undefined || preset === '' || preset === 'inherit') return;
42
+ const inList = whitelist instanceof Set ? whitelist.has(preset) : Array.isArray(whitelist) && whitelist.includes(preset);
43
+ if (!inList) {
44
+ throw new Error(`evolution:advice: 候选预设 "${preset}" 不在 system-trust 白名单(拒绝非 system 候选)`);
45
+ }
46
+ }
47
+
48
+ function performanceText(entry) {
49
+ const outcome = entry?.outcome ?? {};
50
+ const perf = entry?.perf ?? {};
51
+ const parts = [
52
+ `过去 ${entry?.deployments_total ?? 0} 次:完成 ${outcome.completed ?? 0}、失败 ${outcome.failed ?? 0}、终止 ${outcome.killed ?? 0}`,
53
+ `加权成功分 ${entry?.score?.weighted_success ?? 0}`,
54
+ ];
55
+ if (perf.avg_elapsed_ms !== null && perf.avg_elapsed_ms !== undefined) parts.push(`平均耗时 ${perf.avg_elapsed_ms}ms`);
56
+ if (perf.avg_output_len !== null && perf.avg_output_len !== undefined) parts.push(`平均输出 ${perf.avg_output_len} 字符`);
57
+ return parts.join(',');
58
+ }
59
+
60
+ function directionFor(profileKey, capability, budget) {
61
+ if (budget && !capability) return '升';
62
+ if (capability) return '降';
63
+ return '平';
64
+ }
65
+
66
+ export function suggestAdvice({
67
+ l1Entry,
68
+ profileKey = '',
69
+ minN = 3,
70
+ cooldownMs = 600000,
71
+ now = Date.now(),
72
+ successThreshold = 0.6,
73
+ confidenceMinLevel = 'medium',
74
+ } = {}) {
75
+ const entry = l1Entry !== null && typeof l1Entry === 'object' ? l1Entry : {};
76
+ const key = typeof profileKey === 'string' ? profileKey : (typeof entry.profileKey === 'string' ? entry.profileKey : '');
77
+ const n = Number.isFinite(entry.confidence?.n) ? entry.confidence.n : 0;
78
+ const level = typeof entry.confidence?.level === 'string' ? entry.confidence.level : confidenceLevel(n, minN);
79
+ const score = Number.isFinite(entry.score?.weighted_success) ? entry.score.weighted_success : 0;
80
+ const until = entry.cooldown?.until_ts;
81
+
82
+ // 四条件齐备才产出(任一不满足返回 null)。
83
+ if (n < minN) return null;
84
+ if ((CONFIDENCE_RANK[level] ?? -1) < (CONFIDENCE_RANK[confidenceMinLevel] ?? 0)) return null;
85
+ if (until !== null && until !== undefined && now < until) return null;
86
+ if (!(score < successThreshold)) return null;
87
+
88
+ // 方向判定:优先读组内轴存在性(L1 键去 effort 后由聚合写入);legacy summaries
89
+ // 无 axes 字段时回退键文本正则判定(保持旧 summaries.json 可读)。
90
+ const axes = entry.axes !== null && typeof entry.axes === 'object' ? entry.axes : null;
91
+ const capability = axes !== null && typeof axes.capability === 'boolean' ? axes.capability : hasCapabilityAxis(key);
92
+ const budget = axes !== null && typeof axes.budget === 'boolean' ? axes.budget : hasBudgetAxis(key);
93
+
94
+ return {
95
+ profileKey: key,
96
+ performanceText: performanceText(entry),
97
+ suggestion: directionFor(key, capability, budget),
98
+ confidence: level,
99
+ cooldownUntil: now + cooldownMs,
100
+ };
101
+ }
102
+
103
+ // --- 派发优化建议(evolution:advice 注入段,默认关)----------------------------
104
+
105
+ // 读 summaries.json(存在才读;损坏/形状不符 → null,fail-soft)。
106
+ // 版本化:只接受 v:1;未知/超前版本 fail-soft 跳过(v1 无前驱,迁移表空)。
107
+ export function readSummaries(file, logger) {
108
+ try {
109
+ if (!existsSync(file)) return null;
110
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
111
+ if (parsed === null || typeof parsed !== 'object' || parsed.l1 === null || typeof parsed.l1 !== 'object') return null;
112
+ if (parsed.v !== 1) {
113
+ if (logger !== undefined && typeof logger.warn === 'function') {
114
+ logger.warn(`[dsh-subagent-profile] summaries.json 版本 ${String(parsed.v)} 未知,跳过该资产(fail-soft)`);
115
+ }
116
+ return null;
117
+ }
118
+ return parsed;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ // 生产聚合触发点(T1 修复):读 dispatch.jsonl → parseDispatchRecords → computeSummaries
125
+ // → writeSummaries。opts 可注入 parentAdoptedConfirmedFalse join(adoption-tracker 的
126
+ // 「已确认未采纳」计数,惰性 join 进 weighted_success)。缺失台账返回 skipped:'no-ledger'。
127
+ export function refreshSummaries({ dispatchFile, summariesFile, logger, opts = {} } = {}) {
128
+ if (!existsSync(dispatchFile)) return { persisted: false, skipped: 'no-ledger' };
129
+ let lines;
130
+ try {
131
+ lines = readFileSync(dispatchFile, 'utf8').split('\n');
132
+ } catch (error) {
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ if (logger !== undefined && typeof logger.warn === 'function') {
135
+ logger.warn(`[dsh-subagent-profile] evolution:advice 读派发台账失败:${message}`);
136
+ }
137
+ return { persisted: false, error: message };
138
+ }
139
+ const { records, skipped } = parseDispatchRecords(lines);
140
+ const summaries = computeSummaries(records, opts);
141
+ const res = writeSummaries(summariesFile, summaries);
142
+ return { ...res, records: records.length, skippedLines: skipped };
143
+ }
144
+
145
+ // summaries.json 是否过期(缺失但台账存在,或台账 mtime 更新)——惰性重算判据。
146
+ function summariesStale(dispatchFile, summariesFile) {
147
+ if (!existsSync(summariesFile)) return existsSync(dispatchFile);
148
+ try {
149
+ return statSync(dispatchFile).mtimeMs > statSync(summariesFile).mtimeMs;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // 注入段文本:全局聚合摘要 + 各 profile 一条确定性建议(人类读、非指令)。
156
+ export function renderAdviceText(advice, global) {
157
+ const direction = (value) => (value === '升' ? '可上调预算' : value === '降' ? '建议降级' : '维持现状');
158
+ const lines = advice.map((a) => {
159
+ const untilText = Number.isFinite(a.cooldownUntil)
160
+ ? `(冷却至 ${new Date(a.cooldownUntil).toLocaleTimeString('zh-CN', { hour12: false })})`
161
+ : '';
162
+ return `- ${a.profileKey}:${a.performanceText};${direction(a.suggestion)}(置信度 ${a.confidence})${untilText}`;
163
+ });
164
+ const total = global.completed + global.failed + global.killed;
165
+ return `以下为过去派发的只读统计与确定性建议(仅供人类参考,不改变派发行为):\n` +
166
+ `全局:累计 ${total} 次(完成 ${global.completed}、失败 ${global.failed}、终止 ${global.killed})。\n${lines.join('\n')}`;
167
+ }
168
+
169
+ // 建议产出后把各 profile 的新 cooldown.until_ts 写回 summaries.json(原子写、
170
+ // fail-soft)。写前重读磁盘再合并:并发注入各自基于最新磁盘态写回,把互相覆盖
171
+ // 窗口压到最小(残余竞态:读-写间隙,彻底解决需进程内锁,成本不值)。
172
+ function writeBackCooldowns(file, data, produced, logger) {
173
+ const fresh = readSummaries(file, logger);
174
+ const base = fresh !== null && typeof fresh === 'object' ? fresh : data;
175
+ const l1 = { ...(base.l1 ?? {}) };
176
+ for (const [profileKey, until] of produced) {
177
+ const group = l1[profileKey];
178
+ l1[profileKey] = { ...group, cooldown: { ...(group?.cooldown ?? {}), until_ts: until } };
179
+ }
180
+ const res = writeSummaries(file, { l1, l2: base.l2 ?? {} });
181
+ if (res.persisted !== true) {
182
+ logger.warn(`[dsh-subagent-profile] evolution:advice cooldown 写回失败:${res.error ?? '未知错误'}`);
183
+ }
184
+ }
185
+
186
+ // 结构化建议(读+算,不写回 cooldown、不渲染文本):供注入段与只读面板共用。
187
+ export function computeAdvice({ summariesFile, dispatchFile, whitelist, logger }) {
188
+ if (dispatchFile !== undefined && summariesStale(dispatchFile, summariesFile)) refreshSummaries({ dispatchFile, summariesFile, logger });
189
+ let data = readSummaries(summariesFile, logger);
190
+ // summaries.json 存在但不可读(损坏/版本不符)→ fail-soft 重建(幂等可复算),
191
+ // 损坏资产不阻断建议产出;台账缺失时维持空建议(refreshSummaries 的 no-ledger 语义)。
192
+ if (data === null && dispatchFile !== undefined && existsSync(summariesFile)) {
193
+ refreshSummaries({ dispatchFile, summariesFile, logger });
194
+ data = readSummaries(summariesFile, logger);
195
+ }
196
+ if (data === null) return { advice: [], global: { completed: 0, failed: 0, killed: 0, total: 0 }, produced: [], summaries: null };
197
+ const l1 = data.l1 ?? {};
198
+ const keys = Object.keys(l1).sort();
199
+ const advice = [];
200
+ const produced = [];
201
+ const global = { completed: 0, failed: 0, killed: 0 };
202
+ for (const profileKey of keys) {
203
+ const group = l1[profileKey];
204
+ if (group === null || typeof group !== 'object') continue;
205
+ global.completed += group.outcome?.completed ?? 0;
206
+ global.failed += group.outcome?.failed ?? 0;
207
+ global.killed += group.outcome?.killed ?? 0;
208
+ const suggestion = suggestAdvice({ l1Entry: group, profileKey });
209
+ if (suggestion === null) continue;
210
+ // 只对实际产出的建议键校验候选(N 不足不产出建议的组不要求 system-trust)。
211
+ assertSystemCandidate(whitelist, presetFromKey(profileKey));
212
+ advice.push(suggestion);
213
+ produced.push([profileKey, suggestion.cooldownUntil]);
214
+ }
215
+ return { advice, global: { ...global, total: global.completed + global.failed + global.killed }, produced, summaries: data };
216
+ }
217
+
218
+ // 派发优化建议段文本生成:computeAdvice → 写回 cooldown(防每请求重复注入)→ 渲染;空建议返回空串。
219
+ export function buildAdviceText({ summariesFile, dispatchFile, whitelist, logger }) {
220
+ const { advice, global, produced, summaries } = computeAdvice({ summariesFile, dispatchFile, whitelist, logger });
221
+ if (advice.length === 0) return '';
222
+ writeBackCooldowns(summariesFile, summaries, produced, logger);
223
+ return renderAdviceText(advice, global);
224
+ }