dsh-subagent-profile 0.3.0 → 0.3.2

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.
@@ -1,4 +1,4 @@
1
- // lib/core/dispatch-tool.mjs — `dispatch` 工具(defineTool schema + execute)、
1
+ // lib/core/dispatch-tool.mjs — `dispatch` 工具(defineTool schema + execute)、
2
2
  // 结果 schema 一致性锁与 syncTool 注册/注销逻辑,从 index.mjs 逐字拆出。
3
3
  // 仅引用 lib + shims;无 @deepseek-ai 依赖(shims 是唯一入口)。
4
4
  //
@@ -17,14 +17,16 @@
17
17
  // routes (/set-enabled), dispose runs on plugin teardown.
18
18
  //
19
19
  // execute 按预检段 + 前台/后台/continuable 三分支拆为模块级私有函数;
20
- // parameters/output 声明为纯数据,驻留模块级常量(与数据表同性质,不受函数
21
- // 行门约束)——工厂保持装配态,行为逐字不变。
20
+ // parameters 声明为纯数据,驻留模块级常量(与数据表同性质,不受函数行门约束);
21
+ // output 结果 schema 已拆至 lib/core/dispatch-schema.mjs(守文件行门)——
22
+ // 工厂保持装配态;除各任务明示的新增行为外,执行路径零漂移。
22
23
 
23
24
  import { defineTool } from './shims.mjs';
24
25
  import { computeContinuableAllow, textFrom, stopReasonError, withPartialText, pruneBlocks, assertResultSchemaConsistency } from './pure.mjs';
25
26
  import { resolveWhitelist } from './whitelist.mjs';
26
27
  import { assertCostGuard } from './cost-guard.mjs';
27
- import { settleStart } from './delegation.mjs';
28
+ import { settleStart, collectChildUsage } from './delegation.mjs';
29
+ import { DISPATCH_OUTPUT_SCHEMA } from './dispatch-schema.mjs';
28
30
 
29
31
  // --- 工具声明纯数据(从工厂提到模块级;defineTool 只读不改)----------------------
30
32
 
@@ -34,6 +36,7 @@ const DISPATCH_PARAMETERS = {
34
36
  model: { type: 'string', description: 'Explicit model override for the child.' },
35
37
  provider: { type: 'string', description: 'Explicit provider override for the child.' },
36
38
  reasoningEffort: { type: 'string', description: 'Explicit reasoning-effort override injected into every child request.' },
39
+ tokenTier: { type: 'string', enum: ['cheap', 'balanced', 'premium'], description: '成本/深度分层(估算口径,非计费):cheap 省 token、balanced 均衡、premium 高成本。覆盖 profile 的 tokenTier;缺省 balanced。' },
37
40
  persona: { type: 'string', description: 'Persona text shadowing the child deployment:persona section.' },
38
41
  toolFilter: {
39
42
  type: 'object',
@@ -50,65 +53,18 @@ const DISPATCH_PARAMETERS = {
50
53
  maxDepth: { type: 'number', description: 'Absolute delegation-depth cap for this child.' },
51
54
  run_in_background: { type: 'boolean', description: '异步 one-shot:走 jobs.start 包 start(),返回 jobId;仍单轮即弃,非 continuable' },
52
55
  continuable: { type: 'boolean', description: 'Start a durable continuable subagent instead of a one-shot: returns a subagentId immediately and keeps the child conversation available for later turns via the send_message tool. Defaults to false.' },
53
- // 信封模式 = opt-in(仅「中段即交付物」的任务用)。当前**仅预留**:工具
54
- // schema 暴露此参数作字段契约,execute 不消费它(结构化信封回收尚未启用)。
55
- // execute 内注释。
56
- envelope: { type: 'boolean', description: '预留:结构化信封回收(V2.0 中期启用,当前不生效)' },
56
+ // 信封模式 = opt-in(仅「中段即交付物」的任务用):true 时把信封骨架追加进
57
+ // persona(systemPrompt 影子段,模型可见),子 Agent 按结构化信封汇报;
58
+ // 默认 false 走结果剪枝回收。
59
+ envelope: { type: 'boolean', description: 'true 时子 Agent 按结构化信封汇报(简短结论 + 结构分项 + 关键发现落点),适合中段即交付物的长任务;默认 false 走剪枝回收' },
57
60
  prompt: { type: 'string', required: true, description: 'The complete, self-contained task for the child (it does not see this conversation).' }
58
61
  };
59
62
 
60
- const DISPATCH_OUTPUT_SCHEMA = {
61
- // Observability metadata on every result. OneOf covers the
62
- // background variant (kind/jobId) and the foreground variant (output),
63
- // both closed and both carrying the effective delegation values.
64
- // `ignored` must appear in ALL three branches (with the shared `preset`
65
- // / `provider` / `model` / `reasoningEffort` / `profile`), keeping the
66
- // closed oneOf consistent — assertResultSchemaConsistency(dispatchTool
67
- // .output.schema) in apply() fires if any 分支 忘补该字段.
68
- oneOf: [
69
- {
70
- type: 'object',
71
- additionalProperties: false,
72
- properties: {
73
- kind: { type: 'string', required: true, const: 'background' },
74
- jobId: { type: 'string', required: true },
75
- profile: { type: 'string' },
76
- preset: { type: 'string' },
77
- provider: { type: 'string' },
78
- model: { type: 'string' },
79
- reasoningEffort: { type: 'string' },
80
- ignored: { type: 'array', items: { type: 'string' } }
81
- }
82
- },
83
- {
84
- type: 'object',
85
- additionalProperties: false,
86
- properties: {
87
- kind: { type: 'string', required: true, const: 'continuable' },
88
- subagentId: { type: 'string', required: true },
89
- profile: { type: 'string' },
90
- preset: { type: 'string' },
91
- provider: { type: 'string' },
92
- model: { type: 'string' },
93
- reasoningEffort: { type: 'string' },
94
- ignored: { type: 'array', items: { type: 'string' } }
95
- }
96
- },
97
- {
98
- type: 'object',
99
- additionalProperties: false,
100
- properties: {
101
- output: { type: 'string', required: true },
102
- profile: { type: 'string' },
103
- preset: { type: 'string' },
104
- provider: { type: 'string' },
105
- model: { type: 'string' },
106
- reasoningEffort: { type: 'string' },
107
- ignored: { type: 'array', items: { type: 'string' } }
108
- }
109
- }
110
- ]
111
- };
63
+ // 信封骨架:envelope:true 时追加进子 persona 的结构化汇报契约。三分支共享此
64
+ // 常量;骨架只进 persona(systemPrompt 影子段、模型可见),不进 descriptor
65
+ // (model-hidden、仅 session 记录),故 execute 在三分支创建子 Agent 前统一
66
+ // 追加到 merged.persona。
67
+ const ENVELOPE_SKELETON = '完成前按以下骨架输出:## 结论(1-2 句)\n## 结构分项(逐项)\n## 关键发现落点(文件路径/数据位置)';
112
68
 
113
69
  // continuable 丢弃 preset 换用与 reasoningEffort —— 渲染行把 `ignored`
114
70
  // 列表回显出来(`reasoningEffort=<值>(ignored)`,再加 ignored 项明细),让模型
@@ -117,11 +73,29 @@ const DISPATCH_RENDER = (_args, value) => {
117
73
  const ignored = value.ignored !== undefined && value.ignored.length > 0
118
74
  ? `(ignored: ${value.ignored.join(', ')})`
119
75
  : '';
76
+ const tier = typeof value.tokenTier === 'string' && value.tokenTier !== ''
77
+ ? ` · tokenTier=${value.tokenTier}`
78
+ : '';
79
+ const tokens = typeof value.childTotalTokens === 'number'
80
+ ? ` · childTotalTokens=${value.childTotalTokens}`
81
+ : '';
82
+ const elapsed = typeof value.elapsedMs === 'number'
83
+ ? ` · elapsedMs=${value.elapsedMs}`
84
+ : '';
85
+ const stopReason = typeof value.stopReason === 'string' && value.stopReason !== ''
86
+ ? ` · stopReason=${value.stopReason}`
87
+ : '';
88
+ // childUsage 是嵌套对象,render 行是扁平的 key=value,故 JSON 序列化为单段
89
+ // 供 client 侧 parseDispatchText 解析回对象(五段分解)。仅前台 completed 结算
90
+ // 携带;后台结算经 job 结果携带,continuable 不携带,故该段只在 foreground 行。
91
+ const usage = value.childUsage !== undefined && value.childUsage !== null && typeof value.childUsage === 'object'
92
+ ? ` · childUsage=${JSON.stringify(value.childUsage)}`
93
+ : '';
120
94
  const text = value.kind === 'background'
121
- ? `[dispatch] background job ${value.jobId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}`
95
+ ? `[dispatch] background job ${value.jobId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}`
122
96
  : value.kind === 'continuable'
123
- ? `[dispatch] started subagent ${value.subagentId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}`
124
- : `[dispatch] profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}\n\n${value.output}`;
97
+ ? `[dispatch] started subagent ${value.subagentId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}`
98
+ : `[dispatch] profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}${tokens}${usage}${elapsed}${stopReason}\n\n${value.output}`;
125
99
  return [{ type: 'text', text }];
126
100
  };
127
101
 
@@ -131,7 +105,7 @@ const DISPATCH_RENDER = (_args, value) => {
131
105
  function mergeProfileArgs(args, store) {
132
106
  const base = args.profile !== undefined ? store.resolveProfile(args.profile) : {};
133
107
  const merged = { ...base };
134
- for (const key of ['preset', 'model', 'provider', 'reasoningEffort', 'persona', 'toolFilter', 'maxTokens', 'maxDepth']) {
108
+ for (const key of ['preset', 'model', 'provider', 'reasoningEffort', 'persona', 'toolFilter', 'maxTokens', 'maxDepth', 'tokenTier']) {
135
109
  if (args[key] !== undefined) merged[key] = args[key];
136
110
  }
137
111
  return merged;
@@ -158,7 +132,8 @@ function buildMeta(args, merged, parent) {
158
132
  preset: merged.preset ?? 'inherit',
159
133
  provider: merged.provider ?? parent.options.provider ?? '(parent)',
160
134
  model: merged.model ?? parent.options.model ?? '(parent)',
161
- reasoningEffort: merged.reasoningEffort ?? '(default)'
135
+ reasoningEffort: merged.reasoningEffort ?? '(default)',
136
+ tokenTier: merged.tokenTier ?? 'balanced'
162
137
  };
163
138
  }
164
139
 
@@ -262,6 +237,7 @@ async function runContinuable(args, merged, meta, parent, exec, deps) {
262
237
  provider: meta.provider,
263
238
  model: meta.model,
264
239
  reasoningEffort: meta.reasoningEffort,
240
+ tokenTier: meta.tokenTier,
265
241
  ignored: ['preset', 'reasoningEffort']
266
242
  };
267
243
  }
@@ -271,7 +247,7 @@ async function runContinuable(args, merged, meta, parent, exec, deps) {
271
247
  // Background one-shot (job) path — jobs.start wraps start() with a native
272
248
  // AbortController (a Node global in a bundle; the dynamic-plugin sandbox needed
273
249
  // the hand-rolled shim instead); still one turn, not continuable.
274
- async function runBackground(args, meta, request, parent, deps, pruneResultOutput) {
250
+ async function runBackground(args, meta, request, parent, deps, pruneResultOutput, t0) {
275
251
  const jobs = deps.getService('jobs');
276
252
  if (jobs === undefined) {
277
253
  throw new Error('dispatch: background jobs unavailable (load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs)');
@@ -284,54 +260,109 @@ async function runBackground(args, meta, request, parent, deps, pruneResultOutpu
284
260
  const controller = new AbortController();
285
261
  return {
286
262
  cancel: (reason) => controller.abort(reason ?? 'dispatch: background subagent task killed'),
287
- done: settleStart(deps.subagents.start('profile', { ...request, signal: controller.signal }), controller.signal, meta, pruneResultOutput)
263
+ done: settleStart(
264
+ deps.subagents.start('profile', { ...request, signal: controller.signal }),
265
+ controller.signal,
266
+ meta,
267
+ pruneResultOutput,
268
+ (session) => measureChildTokens(deps.getService, session),
269
+ t0
270
+ )
288
271
  };
289
272
  }
290
273
  });
291
274
  return { kind: 'background', jobId, ...meta };
292
275
  }
293
276
 
277
+ // childTotalTokens:子 Agent 会话的宿主启发式估算 token 总量(surface 口径),
278
+ // 非 provider 计费 usage token。仅 completed 结算时测量;tokenMeter 服务缺失、
279
+ // measure 非函数、返回缺 totalTokens 或抛错时均返回 undefined(fail-soft),
280
+ // 使测量绝不阻断派发结算。
281
+ function measureChildTokens(getService, session) {
282
+ if (session === undefined || session === null) return undefined;
283
+ const meter = getService('tokenMeter');
284
+ if (meter === undefined || typeof meter.measure !== 'function') return undefined;
285
+ try {
286
+ const measured = meter.measure(session);
287
+ return measured !== null && typeof measured === 'object' && typeof measured.totalTokens === 'number'
288
+ ? measured.totalTokens
289
+ : undefined;
290
+ } catch {
291
+ return undefined;
292
+ }
293
+ }
294
+
294
295
  // Foreground: collect, always release the handle (dispose even when
295
296
  // run.result rejects), then fail loud on a non-completed stop reason.
296
- async function runForeground(meta, request, parent, deps, pruneResultOutput) {
297
+ async function runForeground(meta, request, parent, deps, pruneResultOutput, t0) {
297
298
  const run = await deps.subagents.start('profile', request);
298
299
  let result;
300
+ let childTotalTokens;
301
+ let childUsage;
299
302
  try {
300
303
  result = await run.result;
304
+ // 仅 completed 结算时测量;dispose 前读子 session 保证事件流完整。
305
+ if (result.stopReason === 'completed') {
306
+ const childSession = run.localAgent?.session;
307
+ childTotalTokens = measureChildTokens(deps.getService, childSession);
308
+ childUsage = collectChildUsage(childSession);
309
+ }
301
310
  } finally {
302
311
  await run.dispose().catch(() => {});
303
312
  }
304
- // A non-'completed' stop reason is a failure; attach the child's
305
- // partial output text (withPartialText style).
313
+ // completed 视为失败,附子输出部分文本。
306
314
  const failure = stopReasonError(result);
307
315
  if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
308
- return { output: textFrom(pruneResultOutput(result.output)), ...meta };
316
+ // 结算 meta:elapsedMs t0;stopReason 取底层值(此分支必 completed);
317
+ // childTotalTokens / childUsage 仅可测量时携带。
318
+ const metaOut = {
319
+ ...meta,
320
+ ...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
321
+ ...(childUsage !== undefined ? { childUsage } : {}),
322
+ elapsedMs: Date.now() - t0,
323
+ stopReason: result.stopReason
324
+ };
325
+ return { output: textFrom(pruneResultOutput(result.output)), ...metaOut };
309
326
  }
310
327
 
311
328
  // execute 主体:预检段 + 三分支分派,行为逐字不变。
312
329
  async function runDispatch(args, exec, deps) {
313
330
  const parent = exec.agent;
314
331
  if (!parent) throw new Error('dispatch requires calling agent');
332
+ // 派发耗时基准:execute 入口记 t0,前台/后台各自在结算处算 elapsedMs。
333
+ // continuable 不结算、不携带,故 t0 仅穿线到前台/后台。
334
+ const t0 = Date.now();
315
335
  const merged = mergeProfileArgs(args, deps.store);
336
+ // 信封模式 opt-in:三分支创建子 Agent 前,把信封骨架追加进子 persona。
337
+ // persona 是 systemPrompt 影子段(模型可见);descriptor 是 model-hidden,
338
+ // 故骨架只追加 persona、不触碰 descriptor。既有 persona 为空/未设时直接
339
+ // 置为骨架,非空时以空行衔接追加,不覆盖原文。
340
+ if (args.envelope === true) {
341
+ const hasPersona = merged.persona !== undefined && merged.persona.trim() !== '';
342
+ merged.persona = hasPersona
343
+ ? `${merged.persona}\n\n${ENVELOPE_SKELETON}`
344
+ : ENVELOPE_SKELETON;
345
+ }
316
346
  await assertPresetWhitelist(parent, merged);
317
- // Cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控)。
318
- await assertCostGuard(parent, merged, deps.store.getAllowFailOpen(), deps.logger);
347
+ // Cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控;
348
+ // llm 目录读取走共享 catalog 快照)。
349
+ await assertCostGuard(parent, merged, deps.store.getAllowFailOpen(), deps.logger, deps.catalog);
319
350
  const meta = buildMeta(args, merged, parent);
320
351
  const request = buildRequest(args, merged, parent, exec.signal);
321
352
  // 结果回收默认剪枝:在 textFrom(result.output) 之前复用宿主
322
353
  // toolResultPruner.pruneContent 预剪。`pruneResultOutput` 每次现取
323
354
  // ctx.get('toolResultPruner') 以反映服务就绪状态;pruner 缺失时
324
- // pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。envelope 参数虽已在
325
- // 工具 schema 暴露(预留),但 execute **不消费**它。
355
+ // pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。envelope:true 时信封
356
+ // 骨架已在上方追加进 merged.persona(不改变此处的剪枝回收路径)。
326
357
  const pruneResultOutput = (blocks) => pruneBlocks(blocks, deps.getService('toolResultPruner'));
327
358
  logDispatchDecision(deps.logger, args, merged, parent);
328
359
  if (args.continuable === true) return runContinuable(args, merged, meta, parent, exec, deps);
329
- if (args.run_in_background === true) return runBackground(args, meta, request, parent, deps, pruneResultOutput);
330
- return runForeground(meta, request, parent, deps, pruneResultOutput);
360
+ if (args.run_in_background === true) return runBackground(args, meta, request, parent, deps, pruneResultOutput, t0);
361
+ return runForeground(meta, request, parent, deps, pruneResultOutput, t0);
331
362
  }
332
363
 
333
- export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents }) {
334
- const deps = { store, getEnabled, getService, logger, subagents };
364
+ export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog }) {
365
+ const deps = { store, getEnabled, getService, logger, subagents, catalog };
335
366
  const dispatchTool = defineTool({
336
367
  name: 'dispatch',
337
368
  description: 'Dispatch a subtask to a derived subagent, optionally overriding its preset, model, provider, reasoning effort, persona, tool whitelist, token budget, or recursion depth. Foreground waits for the result; run_in_background: true starts a background job (single turn); continuable: true starts a durable subagent whose conversation stays available for later turns via the send_message tool. 前瞻:continuable 模式忽略 preset 换用与 reasoningEffort(结果以 ignored 提示)。',
@@ -1,8 +1,9 @@
1
- // lib/core/http-routes.mjs — settings HTTP loopback routes for the Client UI,
2
- // moved verbatim from index.mjs's `ctx.inject(['webServer'], (scope) => {...})`
3
- // block. Local lib references only: sanitizeProfile from
4
- // lib/core/pure.mjs, TOOL_ZH/TOOL_CATEGORY from lib/core/catalog.mjs, BUILTIN_SEEDS from
5
- // lib/core/profiles-store.mjs; no @deepseek-ai dependency.
1
+ // lib/core/http-routes.mjs — settings HTTP loopback routes for the Client UI.
2
+ // Read routes (list / options summary / per-model efforts / tools) + write
3
+ // routes (set-enabled / add / remove / reset / reset-all / set-profile-enabled).
4
+ // The /options catalog (models / presets / tools / efforts) is served from the
5
+ // shared catalog cache (lib/core/catalog-cache.mjs), so these routes no longer
6
+ // walk the llm directory themselves.
6
7
  //
7
8
  // Injection: every apply-closure / ctx dependency is an explicit parameter —
8
9
  // store the profile store (profiles Map / persistProfiles /
@@ -11,20 +12,17 @@
11
12
  // /set-enabled),
12
13
  // setEnabled writes it,
13
14
  // syncTool unregisters/registers the dispatch tool on /set-enabled,
14
- // getLlm / getAgentPresets / getTools
15
- // request-time service getters (ctx.get('llm') etc. are read
16
- // per request, never at apply time),
17
- // logger ctx.logger (route error / tools-directory warnings).
15
+ // catalog the shared catalog cache (getSnapshot / invalidate),
16
+ // logger ctx.logger (route error warnings).
18
17
  // The factory returns the scope.effect setup function so the caller keeps the
19
18
  // exact original registration shape: effect(() => register + disposer).
20
19
  //
21
- // 8 条路由各抽为模块级处理函数(if 链分发保持);/options 的目录构建抽
22
- // collectModelDirectory / collectSystemPresets / collectToolsDirectory。
23
- // 行为逐字不变(错误文案/状态码/schema)。
20
+ // 写路由各抽为模块级处理函数(if 链分发保持);/options 拆三读路由 + 手动刷新,
21
+ // 目录数据统一来自 catalog 快照。
24
22
 
25
23
  import { sanitizeProfile } from './pure.mjs';
26
- import { TOOL_ZH, TOOL_CATEGORY } from './catalog.mjs';
27
24
  import { BUILTIN_SEEDS } from './profiles-store.mjs';
25
+ import { detectVersions } from './shims.mjs';
28
26
 
29
27
  // Only the loopback interfaces may drive the settings HTTP routes.
30
28
  const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
@@ -80,123 +78,36 @@ async function handleList(deps, res) {
80
78
  return json(res, 200, { ok: true, profiles: listClean(deps.store) });
81
79
  }
82
80
 
83
- // /options 的模型目录 + 每模型 reasoning-effort 等级(llm 可选,失败仅清空)。
84
- async function collectModelDirectory(llm) {
85
- const models = [];
86
- const efforts = {};
87
- const providers = await llm.listProviders();
88
- for (const provider of (providers ?? [])) {
89
- const providerId = provider && provider.id;
90
- if (typeof providerId !== 'string') continue;
91
- let modelList = [];
92
- try { modelList = await llm.listModels(providerId); } catch { /* skip this provider's catalog */ }
93
- for (const model of (modelList ?? [])) {
94
- if (!model || typeof model.id !== 'string') continue;
95
- models.push({
96
- provider: providerId,
97
- providerName: provider.name ?? providerId,
98
- id: model.id,
99
- name: model.name ?? model.id
100
- });
101
- try {
102
- const info = await llm.resolveModelInfo(providerId, model.id);
103
- const effortsList = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : [];
104
- efforts[model.id] = effortsList.map((effort) => ({
105
- id: effort.id,
106
- name: effort.name ?? effort.id,
107
- ...(effort.description !== undefined ? { description: effort.description } : {})
108
- }));
109
- } catch { /* exact-model lookup may reject; skip its efforts */ }
110
- }
111
- }
112
- return { models, efforts };
81
+ // 轻量摘要:enabled + 模型目录(含 provider 信息,客户端据此派生提供方列表)+ 预设名册。
82
+ async function handleSummary(deps, res) {
83
+ const snapshot = await deps.catalog.getSnapshot();
84
+ return json(res, 200, { ok: true, enabled: deps.getEnabled(), models: snapshot.models, presets: snapshot.presets });
113
85
  }
114
86
 
115
- // System-trust presets(agentPresets 可选,fail-soft)。
116
- async function collectSystemPresets(agentPresets) {
117
- const presets = [];
118
- try {
119
- const list = await agentPresets.list();
120
- for (const preset of (list ?? [])) {
121
- if (preset && preset.trust === 'system') {
122
- presets.push({ id: preset.id, name: preset.name ?? preset.id });
123
- }
124
- }
125
- } catch { /* presets roster unavailable; leave empty */ }
126
- return presets;
87
+ // 每模型 reasoning-effort 等级(懒加载;model 命中快照的 efforts 表)。
88
+ async function handleEfforts(deps, url, res) {
89
+ const model = url.searchParams.get('model');
90
+ const snapshot = await deps.catalog.getSnapshot();
91
+ const efforts = (typeof model === 'string' && model !== '' && snapshot.efforts[model] !== undefined) ? snapshot.efforts[model] : [];
92
+ return json(res, 200, { ok: true, efforts });
127
93
  }
128
94
 
129
- // Full tool directory = global layer (deployment plugins) + every preset's
130
- // standing scope (the agent.cordis.yml tool rows). Each tool is tagged with
131
- // its source: 'global' or the preset id — the grouping is fully dynamic,
132
- // derived from the runtime's preset roster.
133
- async function collectToolsDirectory(getTools, getAgentPresets) {
134
- const tools = [];
135
- const seen = new Set();
136
- const OFFICIAL_PRESETS = ['standard', 'code', 'minimal', 'cordis'];
137
- const layerOf = (source) => {
138
- if (source === 'global') return 'plugin';
139
- if (OFFICIAL_PRESETS.includes(source)) return 'core';
140
- return 'custom';
141
- };
142
- const groupOf = (name, source) => {
143
- const layer = layerOf(source);
144
- if (layer === 'core') return TOOL_CATEGORY[name] ?? '其他';
145
- if (layer === 'plugin') return name.includes('_') ? name.split('_')[0] : name;
146
- return source;
147
- };
148
- const push = (schemas, source) => {
149
- for (const s of (Array.isArray(schemas) ? schemas : [])) {
150
- if (!s || typeof s.name !== 'string' || s.name === 'run_code' || seen.has(s.name)) continue;
151
- seen.add(s.name);
152
- tools.push({ name: s.name, description: typeof s.description === 'string' ? s.description : '', zh: TOOL_ZH[s.name] ?? '', source, layer: layerOf(source), group: groupOf(s.name, source) });
153
- }
154
- };
155
- const toolsService = getTools();
156
- if (toolsService && typeof toolsService.schemas === 'function') {
157
- push(toolsService.schemas(), 'global');
158
- const agentPresets = getAgentPresets();
159
- if (agentPresets !== undefined && typeof agentPresets.list === 'function' && typeof agentPresets.standingKeyFor === 'function') {
160
- const presets = await agentPresets.list();
161
- for (const preset of (presets ?? [])) {
162
- if (!preset || typeof preset.id !== 'string') continue;
163
- try {
164
- push(toolsService.schemas(await agentPresets.standingKeyFor(preset.id)), preset.id);
165
- } catch { /* one preset's standing scope unavailable; skip */ }
166
- }
167
- }
168
- }
169
- return tools;
95
+ // 完整工具目录(面板打开时取)。
96
+ async function handleTools(deps, res) {
97
+ const snapshot = await deps.catalog.getSnapshot();
98
+ return json(res, 200, { ok: true, tools: snapshot.tools });
170
99
  }
171
100
 
172
- async function handleOptions(deps, res) {
173
- const models = [];
174
- const efforts = {};
175
- const presets = [];
176
- // Model directory + per-model reasoning-effort levels. The `llm` service
177
- // is optional (headless): a failure only empties the lists, never breaks
178
- // the settings page.
179
- const llm = deps.getLlm();
180
- if (llm !== undefined) {
181
- try {
182
- const { models: found, efforts: levels } = await collectModelDirectory(llm);
183
- models.push(...found);
184
- Object.assign(efforts, levels);
185
- } catch { /* llm directory unavailable; leave options empty */ }
186
- }
187
- // System-trust presets (agentPresets is optional; fail-soft).
188
- const agentPresets = deps.getAgentPresets();
189
- if (agentPresets !== undefined) {
190
- presets.push(...await collectSystemPresets(agentPresets));
191
- }
192
- // Full tool directory = global layer + every preset's standing scope.
193
- let tools = [];
194
- try {
195
- tools = await collectToolsDirectory(deps.getTools, deps.getAgentPresets);
196
- } catch (error) {
197
- deps.logger.warn('[dsh-subagent-profile] tools directory failed:', error instanceof Error ? error.message : String(error));
198
- }
199
- return json(res, 200, { ok: true, enabled: deps.getEnabled(), models, efforts, presets, tools });
101
+ // 版本探测:三包 version + 越界/未知的中文 warnings(纯探测,同步)。
102
+ function handleVersions(res) {
103
+ const { versions, warnings } = detectVersions();
104
+ return json(res, 200, { ok: true, versions, warnings });
105
+ }
106
+
107
+ // 手动刷新:清缓存兜底(TTL 过期前的目录变更经此立即生效)。
108
+ async function handleRefresh(deps, res) {
109
+ deps.catalog.invalidate();
110
+ return json(res, 200, { ok: true, refreshed: true });
200
111
  }
201
112
 
202
113
  async function handleSetEnabled(deps, req, res) {
@@ -222,6 +133,11 @@ async function handleAdd(deps, req, res) {
222
133
  return json(res, 400, { ok: false, error: `写入被拒绝:${detail}` });
223
134
  }
224
135
  const hadToolFilter = profile.toolFilter !== undefined;
136
+ // tokenTier 不参与上方通用 merge 循环:sanitizeProfile 对「未提供」恒回填
137
+ // balanced,若进循环会破坏「未传→保留 existing」语义(编辑内置 researcher
138
+ // 时 cheap 会被重置为 balanced)。故单独用 raw-body 守卫:传了才写(strict
139
+ // 模式下非法值已在上面 400 拒绝,clean.tokenTier 必为合法 enum)。
140
+ const hadTokenTier = profile.tokenTier !== undefined;
225
141
  const existing = deps.store.profiles.get(clean.id);
226
142
  const seed = BUILTIN_SEEDS.find((s) => s.id === clean.id);
227
143
  const isBuiltin = (existing !== undefined && existing.builtin === true) || seed !== undefined;
@@ -245,6 +161,7 @@ async function handleAdd(deps, req, res) {
245
161
  delete merged.toolFilter;
246
162
  }
247
163
  }
164
+ if (hadTokenTier) merged.tokenTier = clean.tokenTier;
248
165
  if (merged.enabled !== undefined) merged.enabled = merged.enabled === false ? false : true;
249
166
  deps.store.profiles.set(merged.id, { ...merged, ...(isBuiltin ? { builtin: true } : {}), persisted: true });
250
167
  deps.store.deletedBuiltins.delete(merged.id);
@@ -297,8 +214,8 @@ async function handleSetProfileEnabled(deps, req, res) {
297
214
  return persistOk(res, { id, enabled: existing.enabled }, deps.store.persistProfiles());
298
215
  }
299
216
 
300
- export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, getLlm, getAgentPresets, getTools, logger }) {
301
- const deps = { store, getEnabled, setEnabled, syncTool, getLlm, getAgentPresets, getTools, logger };
217
+ export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, catalog, logger }) {
218
+ const deps = { store, getEnabled, setEnabled, syncTool, catalog, logger };
302
219
  // 路由分发(if 链保持,判断顺序与 404/500 兜底不变)。
303
220
  const handler = async (req, res) => {
304
221
  const remote = req.socket?.remoteAddress;
@@ -307,7 +224,11 @@ export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syn
307
224
  const sub = (url.pathname.replace(/^\/subagent-profiles/, '') || '/').replace(/\/+$/, '') || '/';
308
225
  try {
309
226
  if (req.method === 'GET' && (sub === '/' || sub === '/list')) return handleList(deps, res);
310
- if (req.method === 'GET' && sub === '/options') return handleOptions(deps, res);
227
+ if (req.method === 'GET' && sub === '/options/summary') return handleSummary(deps, res);
228
+ if (req.method === 'GET' && sub === '/options/versions') return handleVersions(res);
229
+ if (req.method === 'GET' && sub === '/options/efforts') return handleEfforts(deps, url, res);
230
+ if (req.method === 'GET' && sub === '/options/tools') return handleTools(deps, res);
231
+ if (req.method === 'POST' && sub === '/options/refresh') return handleRefresh(deps, res);
311
232
  if (req.method === 'POST' && sub === '/set-enabled') return handleSetEnabled(deps, req, res);
312
233
  if (req.method === 'POST' && sub === '/add') return handleAdd(deps, req, res);
313
234
  if (req.method === 'POST' && sub === '/remove') return handleRemove(deps, req, res);
@@ -1,4 +1,4 @@
1
- // lib/core/profile-provider.mjs — `profile` 子 Agent provider
1
+ // lib/core/profile-provider.mjs — `profile` 子 Agent provider
2
2
  // (setup/start/prepareContinuable),从 index.mjs 的
3
3
  // `ctx.subagents.registerProvider({...})` 块逐字拆出。仅引用 lib + shims;
4
4
  // 无 @deepseek-ai 依赖(shims 是唯一入口)。
@@ -55,8 +55,9 @@ async function runStartPreflight(request, deps) {
55
55
  if (typeof profile.preset === 'string' && profile.preset !== 'inherit' && !whitelist.has(profile.preset)) {
56
56
  throw new Error(`dispatch: preset "${profile.preset}" is not in the target-preset whitelist`);
57
57
  }
58
- // 权威 cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控)。
59
- await assertCostGuard(parent, profile, deps.store.getAllowFailOpen(), deps.logger);
58
+ // 权威 cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控;
59
+ // llm 目录读取走共享 catalog 快照)。
60
+ await assertCostGuard(parent, profile, deps.store.getAllowFailOpen(), deps.logger, deps.catalog);
60
61
  // Delegation depth: shipped helpers — assert the cap value, then resolve
61
62
  // the child depth (parent floor + 1) and enforce the cap.
62
63
  assertSubagentMaxDepth(profile.maxDepth);
@@ -229,8 +230,8 @@ function wireChildLifecycle(handle, request, childId, swapPreset, profile, logge
229
230
  };
230
231
  }
231
232
 
232
- export function createProfileProvider({ subagents, store, getEnabled, logger }) {
233
- const deps = { store, getEnabled, logger };
233
+ export function createProfileProvider({ subagents, store, getEnabled, logger, catalog }) {
234
+ const deps = { store, getEnabled, logger, catalog };
234
235
  return subagents.registerProvider({
235
236
  name: 'profile',
236
237
  capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
@@ -1,4 +1,4 @@
1
- // lib/core/profiles-store.mjs — profile registry store and enable/disable switch
1
+ // lib/core/profiles-store.mjs — profile registry store and enable/disable switch
2
2
  // (moved verbatim from index.mjs and refactored into a factory; import-free —
3
3
  // node builtins + lib/core/pure.mjs only, no @deepseek-ai dependency).
4
4
  //
@@ -42,8 +42,8 @@ export function dshHome() {
42
42
  // connection.defaults.thinking — this deployment leaves thinking unset,
43
43
  // so the full set is advertised for deepseek-v4-flash).
44
44
  export const BUILTIN_SEEDS = [
45
- { id: 'swap-standard', name: '标准编码', description: '切换到 standard 预设的完整编码工具集。当父会话不是 standard、但子任务需要完整编码能力时用。', preset: 'standard', builtin: true },
46
- { id: 'researcher', name: '调研检索', description: '关闭深度推理省 token,继承父工具。适合查资料、汇总、背景调研,不适合改代码。', reasoningEffort: 'off', persona: 'You are a research subagent: search, read, and summarize only. Do not modify code or files.', builtin: true }
45
+ { id: 'swap-standard', name: '标准编码', description: '切换到 standard 预设的完整编码工具集。当父会话不是 standard、但子任务需要完整编码能力时用。', preset: 'standard', tokenTier: 'balanced', builtin: true },
46
+ { id: 'researcher', name: '调研检索', description: '关闭深度推理省 token,继承父工具。适合查资料、汇总、背景调研,不适合改代码。', reasoningEffort: 'off', persona: 'You are a research subagent: search, read, and summarize only. Do not modify code or files.', tokenTier: 'cheap', builtin: true }
47
47
  ];
48
48
 
49
49
  // --- 模块级 store 函数(从工厂拆出;per-apply 状态经 `state` 注入)---------------