dsh-subagent-profile 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,394 @@
1
+ // lib/core/dispatch-tool.mjs — `dispatch` 工具(defineTool schema + execute)、
2
+ // 结果 schema 一致性锁与 syncTool 注册/注销逻辑,从 index.mjs 逐字拆出。
3
+ // 仅引用 lib + shims;无 @deepseek-ai 依赖(shims 是唯一入口)。
4
+ //
5
+ // Injection: every apply-closure / ctx dependency is an explicit parameter —
6
+ // register ctx.tools.register (the tool is registered/unregistered by
7
+ // syncTool, so the settings switch can remove it at runtime),
8
+ // store the profile store (resolveProfile for the base profile,
9
+ // getAllowFailOpen for the cost guard),
10
+ // getEnabled reads the apply-closure `enabled` flag (continuable fail-loud
11
+ // gate + syncTool registration condition),
12
+ // getService request-time service getter (ctx.get('toolResultPruner') /
13
+ // ctx.get('jobs') are read per call, never at apply time),
14
+ // logger ctx.logger (decision-level dispatch log + continuable warns),
15
+ // subagents ctx.subagents (start / startContinuable drive the child).
16
+ // The factory returns { syncTool, dispose }: syncTool is handed to the HTTP
17
+ // routes (/set-enabled), dispose runs on plugin teardown.
18
+ //
19
+ // execute 按预检段 + 前台/后台/continuable 三分支拆为模块级私有函数;
20
+ // parameters 声明为纯数据,驻留模块级常量(与数据表同性质,不受函数行门约束);
21
+ // output 结果 schema 已拆至 lib/core/dispatch-schema.mjs(守文件行门)——
22
+ // 工厂保持装配态;除各任务明示的新增行为外,执行路径零漂移。
23
+
24
+ import { defineTool } from './shims.mjs';
25
+ import { computeContinuableAllow, textFrom, stopReasonError, withPartialText, pruneBlocks, assertResultSchemaConsistency } from './pure.mjs';
26
+ import { resolveWhitelist } from './whitelist.mjs';
27
+ import { assertCostGuard } from './cost-guard.mjs';
28
+ import { settleStart } from './delegation.mjs';
29
+ import { DISPATCH_OUTPUT_SCHEMA } from './dispatch-schema.mjs';
30
+
31
+ // --- 工具声明纯数据(从工厂提到模块级;defineTool 只读不改)----------------------
32
+
33
+ const DISPATCH_PARAMETERS = {
34
+ profile: { type: 'string', description: 'Optional profile id from the profile registry (built-ins: swap-standard, researcher, plus any you define in the settings page); omit to inherit the parent preset and tools as-is.' },
35
+ preset: { type: 'string', description: 'Explicit target preset override; must be a system-trust preset of this runtime.' },
36
+ model: { type: 'string', description: 'Explicit model override for the child.' },
37
+ provider: { type: 'string', description: 'Explicit provider override for the child.' },
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。' },
40
+ persona: { type: 'string', description: 'Persona text shadowing the child deployment:persona section.' },
41
+ toolFilter: {
42
+ type: 'object',
43
+ // DSL 对象参数默认拒绝未知键,toolFilter 必须闭合其 schema,
44
+ // 否则 defineTool 在 apply 时 throw、插件加载失败。
45
+ additionalProperties: false,
46
+ description: 'Extra tool whitelist intersection for the child (intersected with the parent tool set).',
47
+ properties: {
48
+ allow: { type: 'array', items: { type: 'string' }, description: 'When present, only these tool names are kept.' },
49
+ deny: { type: 'array', items: { type: 'string' }, description: 'These tool names are always removed.' }
50
+ }
51
+ },
52
+ maxTokens: { type: 'number', description: 'Explicit max-tokens budget for the child.' },
53
+ maxDepth: { type: 'number', description: 'Absolute delegation-depth cap for this child.' },
54
+ run_in_background: { type: 'boolean', description: '异步 one-shot:走 jobs.start 包 start(),返回 jobId;仍单轮即弃,非 continuable' },
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.' },
56
+ // 信封模式 = opt-in(仅「中段即交付物」的任务用):true 时把信封骨架追加进
57
+ // 子 persona(systemPrompt 影子段,模型可见),子 Agent 按结构化信封汇报;
58
+ // 默认 false 走结果剪枝回收。
59
+ envelope: { type: 'boolean', description: 'true 时子 Agent 按结构化信封汇报(简短结论 + 结构分项 + 关键发现落点),适合中段即交付物的长任务;默认 false 走剪枝回收' },
60
+ prompt: { type: 'string', required: true, description: 'The complete, self-contained task for the child (it does not see this conversation).' }
61
+ };
62
+
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## 关键发现落点(文件路径/数据位置)';
68
+
69
+ // continuable 丢弃 preset 换用与 reasoningEffort —— 渲染行把 `ignored`
70
+ // 列表回显出来(`reasoningEffort=<值>(ignored)`,再加 ignored 项明细),让模型
71
+ // 「看见」被丢弃项;background/foreground 无忽略项时该后缀为空。
72
+ const DISPATCH_RENDER = (_args, value) => {
73
+ const ignored = value.ignored !== undefined && value.ignored.length > 0
74
+ ? `(ignored: ${value.ignored.join(', ')})`
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
+ const text = value.kind === 'background'
89
+ ? `[dispatch] background job ${value.jobId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}`
90
+ : value.kind === 'continuable'
91
+ ? `[dispatch] started subagent ${value.subagentId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}`
92
+ : `[dispatch] profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}${tier}${tokens}${elapsed}${stopReason}\n\n${value.output}`;
93
+ return [{ type: 'text', text }];
94
+ };
95
+
96
+ // --- execute 预检段(从 execute 拆出;行为逐字不变)-----------------------------
97
+
98
+ // Resolve the base profile (side channel), then overlay explicit args.
99
+ function mergeProfileArgs(args, store) {
100
+ const base = args.profile !== undefined ? store.resolveProfile(args.profile) : {};
101
+ const merged = { ...base };
102
+ for (const key of ['preset', 'model', 'provider', 'reasoningEffort', 'persona', 'toolFilter', 'maxTokens', 'maxDepth', 'tokenTier']) {
103
+ if (args[key] !== undefined) merged[key] = args[key];
104
+ }
105
+ return merged;
106
+ }
107
+
108
+ // Pre-check: explicit concrete preset must be in the runtime-derived whitelist;
109
+ // a preset equal to the parent's composed preset is rewritten to
110
+ // 'inherit' (no swap).
111
+ async function assertPresetWhitelist(parent, merged) {
112
+ if (typeof merged.preset !== 'string' || merged.preset === 'inherit') return;
113
+ const whitelist = new Set(await resolveWhitelist(parent.ctx.get('agentPresets')));
114
+ if (!whitelist.has(merged.preset)) {
115
+ throw new Error(`dispatch: preset "${merged.preset}" is not in the target-preset whitelist`);
116
+ }
117
+ const parentPresets = parent.ctx.get('agentPresets');
118
+ const parentComposed = parentPresets !== undefined ? parentPresets.composedPreset(parent.ctx) : undefined;
119
+ if (merged.preset === parentComposed) merged.preset = 'inherit';
120
+ }
121
+
122
+ // Effective delegation values for observability.
123
+ function buildMeta(args, merged, parent) {
124
+ return {
125
+ profile: args.profile ?? '(inline)',
126
+ preset: merged.preset ?? 'inherit',
127
+ provider: merged.provider ?? parent.options.provider ?? '(parent)',
128
+ model: merged.model ?? parent.options.model ?? '(parent)',
129
+ reasoningEffort: merged.reasoningEffort ?? '(default)',
130
+ tokenTier: merged.tokenTier ?? 'balanced'
131
+ };
132
+ }
133
+
134
+ // Assemble the foreground/background request (continuable builds its own below).
135
+ function buildRequest(args, merged, parent, signal) {
136
+ return {
137
+ label: String(args.prompt ?? '').slice(0, 60),
138
+ prompt: [{ type: 'text', text: args.prompt }],
139
+ parent,
140
+ signal,
141
+ profile: merged,
142
+ ...(merged.persona !== undefined ? { persona: merged.persona } : {}),
143
+ ...(merged.toolFilter !== undefined ? { toolFilter: merged.toolFilter } : {}),
144
+ ...(merged.maxDepth !== undefined ? { maxDepth: merged.maxDepth } : {})
145
+ };
146
+ }
147
+
148
+ // Decision-level log: resolved effective delegation inputs, after the cost
149
+ // guard and after request assembly, before dispatch.
150
+ function logDispatchDecision(logger, args, merged, parent) {
151
+ logger.info('[dsh-subagent-profile] dispatch:', JSON.stringify({
152
+ profile: args.profile ?? '(inline)',
153
+ preset: merged.preset ?? 'inherit',
154
+ provider: merged.provider ?? parent.options.provider ?? '(parent)',
155
+ model: merged.model ?? parent.options.model ?? '(parent)',
156
+ reasoningEffort: merged.reasoningEffort ?? '(default)',
157
+ maxDepth: merged.maxDepth ?? null,
158
+ background: args.run_in_background === true,
159
+ continuable: args.continuable === true
160
+ }));
161
+ }
162
+
163
+ // --- continuable 分支(从 execute 拆出)------------------------------------------
164
+
165
+ // 预加工 toolFilter 为闭集 allow(父工具集 − run_code − deny):continuable
166
+ // 走宿主 applyChildComposition→tools.restrict,prepareContinuable 返回 {},
167
+ // 插件侧无法重算父∩子交集,故在此把 allow 预加工为闭集传到 request。
168
+ // 假设:continuable 继承父预设(preset swap 被忽略)⇒ 子工具集 ≈ 父工具集;
169
+ // 失效条件:任何导致子工具集与父工具集不一致的宿主行为变化,父集都可能含
170
+ // 子集上不存在之工具 → tools.restrict 抛「未知工具」→ 本缓解自动降级为
171
+ // fail-loud(保守安全)。
172
+ function buildContinuableRequest(args, merged, parent) {
173
+ const parentNames = new Set(parent.ctx.tools.schemas(parent).map((schema) => schema.name));
174
+ const effectiveAllow = computeContinuableAllow(parentNames, merged.toolFilter);
175
+ const hasAgentOptions = merged.provider !== undefined || merged.model !== undefined || merged.maxTokens !== undefined;
176
+ return {
177
+ prompt: [{ type: 'text', text: args.prompt }],
178
+ parent,
179
+ ...(hasAgentOptions ? { agentOptions: {
180
+ ...(merged.provider !== undefined ? { provider: merged.provider } : {}),
181
+ ...(merged.model !== undefined ? { model: merged.model } : {}),
182
+ ...(merged.maxTokens !== undefined ? { maxTokens: merged.maxTokens } : {})
183
+ } } : {}),
184
+ ...(merged.persona !== undefined ? { persona: merged.persona } : {}),
185
+ // 恒传闭集 allow(覆盖原 merged.toolFilter 透传);空集在
186
+ // computeContinuableAllow 内 fail-loud。
187
+ toolFilter: { allow: effectiveAllow },
188
+ ...(merged.maxDepth !== undefined ? { maxDepth: merged.maxDepth } : {})
189
+ };
190
+ }
191
+
192
+ // Continuable (durable) path — startContinuable publishes a persistent child
193
+ // and returns its durable id; the official send_message tool drives later
194
+ // turns. Treated first so a caller asking for both background and continuable
195
+ // gets the continuable child.
196
+ async function runContinuable(args, merged, meta, parent, exec, deps) {
197
+ // provider `start` 的 !enabled 检查只拦
198
+ // `start`,不拦 `startContinuable` —— 这里显式补上。当前 syncTool 会在
199
+ // 禁用时注销 dispatch 工具(间接门),此处是防御性兜底:禁用后
200
+ // dispatch(continuable:true) 必须 fail-loud,不得静默派生子树。
201
+ if (!deps.getEnabled()) {
202
+ throw new Error('dispatch: 插件已禁用(设置 → 子 Agent 方案 重新启用)');
203
+ }
204
+ if (args.run_in_background === true) {
205
+ deps.logger.warn('[dsh-subagent-profile] dispatch: both continuable and run_in_background are true; continuable takes precedence');
206
+ }
207
+ // 已知降级:continuable 标准路径不支持 preset swap 和 reasoningEffort(subagent 包的 SubagentStartRequest 无 preset 字段、AgentOptions 无 reasoningEffort 字段)
208
+ if (merged.preset !== undefined && merged.preset !== 'inherit') {
209
+ deps.logger.warn(`[dsh-subagent-profile] continuable mode cannot swap preset; ignoring "${merged.preset}" (child inherits the parent preset)`);
210
+ }
211
+ if (merged.reasoningEffort !== undefined) {
212
+ deps.logger.warn(`[dsh-subagent-profile] continuable mode cannot set reasoningEffort; ignoring "${merged.reasoningEffort}"`);
213
+ }
214
+ const continuableRequest = buildContinuableRequest(args, merged, parent);
215
+ const { childId } = await deps.subagents.startContinuable({
216
+ provider: 'profile',
217
+ label: String(args.prompt ?? '').slice(0, 60),
218
+ request: continuableRequest,
219
+ signal: exec.signal
220
+ });
221
+ // Continuable drops the profile's preset swap and reasoningEffort (the child
222
+ // inherits the parent preset), so the observability meta must report what
223
+ // actually took effect, not the requested-but-ignored values.
224
+ // 可见性修复:`reasoningEffort` 回显**请求值**(经 meta.reasoningEffort),
225
+ // `preset:'inherit'` 是真实生效值;`ignored` 明确列出被丢弃项。
226
+ return {
227
+ kind: 'continuable',
228
+ subagentId: childId,
229
+ profile: meta.profile,
230
+ preset: 'inherit',
231
+ provider: meta.provider,
232
+ model: meta.model,
233
+ reasoningEffort: meta.reasoningEffort,
234
+ tokenTier: meta.tokenTier,
235
+ ignored: ['preset', 'reasoningEffort']
236
+ };
237
+ }
238
+
239
+ // --- 后台 / 前台分支(从 execute 拆出)--------------------------------------------
240
+
241
+ // Background one-shot (job) path — jobs.start wraps start() with a native
242
+ // AbortController (a Node global in a bundle; the dynamic-plugin sandbox needed
243
+ // the hand-rolled shim instead); still one turn, not continuable.
244
+ async function runBackground(args, meta, request, parent, deps, pruneResultOutput, t0) {
245
+ const jobs = deps.getService('jobs');
246
+ if (jobs === undefined) {
247
+ throw new Error('dispatch: background jobs unavailable (load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs)');
248
+ }
249
+ const jobId = jobs.start({
250
+ kind: 'subagent',
251
+ label: String(args.prompt ?? '').slice(0, 60),
252
+ owner: parent,
253
+ run: () => {
254
+ const controller = new AbortController();
255
+ return {
256
+ cancel: (reason) => controller.abort(reason ?? 'dispatch: background subagent task killed'),
257
+ done: settleStart(
258
+ deps.subagents.start('profile', { ...request, signal: controller.signal }),
259
+ controller.signal,
260
+ meta,
261
+ pruneResultOutput,
262
+ (session) => measureChildTokens(deps.getService, session),
263
+ t0
264
+ )
265
+ };
266
+ }
267
+ });
268
+ return { kind: 'background', jobId, ...meta };
269
+ }
270
+
271
+ // childTotalTokens:子 Agent 会话的宿主启发式估算 token 总量(surface 口径),
272
+ // 非 provider 计费 usage token。仅 completed 结算时测量;tokenMeter 服务缺失、
273
+ // measure 非函数、返回缺 totalTokens 或抛错时均返回 undefined(fail-soft),
274
+ // 使测量绝不阻断派发结算。
275
+ function measureChildTokens(getService, session) {
276
+ if (session === undefined || session === null) return undefined;
277
+ const meter = getService('tokenMeter');
278
+ if (meter === undefined || typeof meter.measure !== 'function') return undefined;
279
+ try {
280
+ const measured = meter.measure(session);
281
+ return measured !== null && typeof measured === 'object' && typeof measured.totalTokens === 'number'
282
+ ? measured.totalTokens
283
+ : undefined;
284
+ } catch {
285
+ return undefined;
286
+ }
287
+ }
288
+
289
+ // Foreground: collect, always release the handle (dispose even when
290
+ // run.result rejects), then fail loud on a non-completed stop reason.
291
+ async function runForeground(meta, request, parent, deps, pruneResultOutput, t0) {
292
+ const run = await deps.subagents.start('profile', request);
293
+ let result;
294
+ let childTotalTokens;
295
+ try {
296
+ result = await run.result;
297
+ // 仅 completed 结算时测量(stopReason 非 completed 走失败路径,不测);
298
+ // 在 dispose 之前读子 session,保证 measure 拿到完整 surface。
299
+ if (result.stopReason === 'completed') {
300
+ childTotalTokens = measureChildTokens(deps.getService, run.localAgent?.session);
301
+ }
302
+ } finally {
303
+ await run.dispose().catch(() => {});
304
+ }
305
+ // A non-'completed' stop reason is a failure; attach the child's
306
+ // partial output text (withPartialText style).
307
+ const failure = stopReasonError(result);
308
+ if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
309
+ // 结算 meta:elapsedMs 从 execute 入口 t0 计;stopReason 取底层结算值
310
+ // (此分支已达 return 必为 completed)。childTotalTokens 仍仅测量时携带。
311
+ const metaOut = {
312
+ ...meta,
313
+ ...(childTotalTokens !== undefined ? { childTotalTokens } : {}),
314
+ elapsedMs: Date.now() - t0,
315
+ stopReason: result.stopReason
316
+ };
317
+ return { output: textFrom(pruneResultOutput(result.output)), ...metaOut };
318
+ }
319
+
320
+ // execute 主体:预检段 + 三分支分派,行为逐字不变。
321
+ async function runDispatch(args, exec, deps) {
322
+ const parent = exec.agent;
323
+ if (!parent) throw new Error('dispatch requires calling agent');
324
+ // 派发耗时基准:execute 入口记 t0,前台/后台各自在结算处算 elapsedMs。
325
+ // continuable 不结算、不携带,故 t0 仅穿线到前台/后台。
326
+ const t0 = Date.now();
327
+ const merged = mergeProfileArgs(args, deps.store);
328
+ // 信封模式 opt-in:三分支创建子 Agent 前,把信封骨架追加进子 persona。
329
+ // persona 是 systemPrompt 影子段(模型可见);descriptor 是 model-hidden,
330
+ // 故骨架只追加 persona、不触碰 descriptor。既有 persona 为空/未设时直接
331
+ // 置为骨架,非空时以空行衔接追加,不覆盖原文。
332
+ if (args.envelope === true) {
333
+ const hasPersona = merged.persona !== undefined && merged.persona.trim() !== '';
334
+ merged.persona = hasPersona
335
+ ? `${merged.persona}\n\n${ENVELOPE_SKELETON}`
336
+ : ENVELOPE_SKELETON;
337
+ }
338
+ await assertPresetWhitelist(parent, merged);
339
+ // Cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控;
340
+ // llm 目录读取走共享 catalog 快照)。
341
+ await assertCostGuard(parent, merged, deps.store.getAllowFailOpen(), deps.logger, deps.catalog);
342
+ const meta = buildMeta(args, merged, parent);
343
+ const request = buildRequest(args, merged, parent, exec.signal);
344
+ // 结果回收默认剪枝:在 textFrom(result.output) 之前复用宿主
345
+ // toolResultPruner.pruneContent 预剪。`pruneResultOutput` 每次现取
346
+ // ctx.get('toolResultPruner') 以反映服务就绪状态;pruner 缺失时
347
+ // pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。envelope:true 时信封
348
+ // 骨架已在上方追加进 merged.persona(不改变此处的剪枝回收路径)。
349
+ const pruneResultOutput = (blocks) => pruneBlocks(blocks, deps.getService('toolResultPruner'));
350
+ logDispatchDecision(deps.logger, args, merged, parent);
351
+ if (args.continuable === true) return runContinuable(args, merged, meta, parent, exec, deps);
352
+ if (args.run_in_background === true) return runBackground(args, meta, request, parent, deps, pruneResultOutput, t0);
353
+ return runForeground(meta, request, parent, deps, pruneResultOutput, t0);
354
+ }
355
+
356
+ export function createDispatchTool({ register, store, getEnabled, getService, logger, subagents, catalog }) {
357
+ const deps = { store, getEnabled, getService, logger, subagents, catalog };
358
+ const dispatchTool = defineTool({
359
+ name: 'dispatch',
360
+ 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 提示)。',
361
+ parameters: DISPATCH_PARAMETERS,
362
+ output: { schema: DISPATCH_OUTPUT_SCHEMA, render: DISPATCH_RENDER },
363
+ isConcurrencySafe: () => true,
364
+ execute: (args, exec) => runDispatch(args, exec, deps),
365
+ });
366
+ // 共享一致性规则 lock: the closed oneOf result schema must carry an identical
367
+ // shared meta key set across all three branches. Fires only at apply time; a
368
+ // future meta-field add that forgets one 分支 throws here (once), so the
369
+ // model-side schema never silently rejects a分支.
370
+ assertResultSchemaConsistency(dispatchTool.output.schema);
371
+ // Register the tool only while enabled; unregister it the moment the switch
372
+ // turns off so it disappears from the model's tool list without a restart.
373
+ let disposeTool;
374
+ function syncTool() {
375
+ if (getEnabled() && disposeTool === undefined) {
376
+ disposeTool = register(dispatchTool);
377
+ } else if (!getEnabled() && disposeTool !== undefined) {
378
+ const dispose = disposeTool;
379
+ disposeTool = undefined;
380
+ dispose();
381
+ }
382
+ }
383
+ syncTool();
384
+ return {
385
+ syncTool,
386
+ dispose() {
387
+ if (disposeTool !== undefined) {
388
+ const dispose = disposeTool;
389
+ disposeTool = undefined;
390
+ dispose();
391
+ }
392
+ }
393
+ };
394
+ }
@@ -0,0 +1,249 @@
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.
7
+ //
8
+ // Injection: every apply-closure / ctx dependency is an explicit parameter —
9
+ // store the profile store (profiles Map / persistProfiles /
10
+ // persistEnabled / deletedBuiltins),
11
+ // getEnabled reads the apply-closure `enabled` flag (mutated by
12
+ // /set-enabled),
13
+ // setEnabled writes it,
14
+ // syncTool unregisters/registers the dispatch tool on /set-enabled,
15
+ // catalog the shared catalog cache (getSnapshot / invalidate),
16
+ // logger ctx.logger (route error warnings).
17
+ // The factory returns the scope.effect setup function so the caller keeps the
18
+ // exact original registration shape: effect(() => register + disposer).
19
+ //
20
+ // 写路由各抽为模块级处理函数(if 链分发保持);/options 拆三读路由 + 手动刷新,
21
+ // 目录数据统一来自 catalog 快照。
22
+
23
+ import { sanitizeProfile } from './pure.mjs';
24
+ import { BUILTIN_SEEDS } from './profiles-store.mjs';
25
+ import { detectVersions } from './shims.mjs';
26
+
27
+ // Only the loopback interfaces may drive the settings HTTP routes.
28
+ const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
29
+
30
+ function json(res, code, data) {
31
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
32
+ res.end(JSON.stringify(data));
33
+ }
34
+
35
+ function readBody(req) {
36
+ return new Promise((resolve, reject) => {
37
+ let data = '';
38
+ let size = 0;
39
+ req.on('data', (chunk) => {
40
+ size += chunk.length;
41
+ if (size > 1 << 20) { reject(new Error('请求体过大')); req.destroy(); return; }
42
+ data += chunk;
43
+ });
44
+ req.on('end', () => {
45
+ try { resolve(data === '' ? {} : JSON.parse(data)); } catch { reject(new Error('请求体不是合法 JSON')); }
46
+ });
47
+ req.on('error', reject);
48
+ });
49
+ }
50
+
51
+ // Write-failure contract: the write routes return HTTP 200 with
52
+ // `persisted` always present; when the disk write failed, persistWarning
53
+ // explains "已保存但未持久化" (in-memory state drives this process, the
54
+ // disk did not update). The client renders that as the amber warning.
55
+ function persistOk(res, payload, persist) {
56
+ return json(res, 200, {
57
+ ok: true,
58
+ ...payload,
59
+ persisted: persist.persisted,
60
+ ...(persist.persisted ? {} : { persistWarning: '已保存但未持久化' }),
61
+ });
62
+ }
63
+
64
+ function listClean(store) {
65
+ return [...store.profiles.values()].map((profile) => {
66
+ const clean = {};
67
+ for (const [key, value] of Object.entries(profile)) if (value !== undefined && key !== 'persisted') clean[key] = value;
68
+ // The internal `persisted` flag is stripped above; expose a UI-facing
69
+ // "modified" signal so the reset panel can label a changed builtin.
70
+ if (profile.builtin === true && profile.persisted === true) clean.modified = true;
71
+ return clean;
72
+ });
73
+ }
74
+
75
+ // --- 路由处理函数(从 createHttpRoutes 拆出;if 链分发在内保持)----------------
76
+
77
+ async function handleList(deps, res) {
78
+ return json(res, 200, { ok: true, profiles: listClean(deps.store) });
79
+ }
80
+
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 });
85
+ }
86
+
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 });
93
+ }
94
+
95
+ // 完整工具目录(面板打开时取)。
96
+ async function handleTools(deps, res) {
97
+ const snapshot = await deps.catalog.getSnapshot();
98
+ return json(res, 200, { ok: true, tools: snapshot.tools });
99
+ }
100
+
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 });
111
+ }
112
+
113
+ async function handleSetEnabled(deps, req, res) {
114
+ const body = await readBody(req);
115
+ const next = !!(body && body.enabled === true);
116
+ deps.setEnabled(next);
117
+ deps.store.persistEnabled(next);
118
+ deps.syncTool();
119
+ return json(res, 200, { ok: true, enabled: deps.getEnabled() });
120
+ }
121
+
122
+ async function handleAdd(deps, req, res) {
123
+ const body = await readBody(req);
124
+ const profile = body && typeof body === 'object' ? body : {};
125
+ if (typeof profile.id !== 'string' || profile.id.length === 0) {
126
+ return json(res, 400, { ok: false, error: 'subagent-profiles: profile id must be a non-empty string' });
127
+ }
128
+ // 写路径上限:strict=true —— 超限/非法字段直接 400 拒绝,
129
+ // 与 loadProfiles(strict=false 迁移宽松读取)的行为区分。列被拒字段与中文原因。
130
+ const { clean, warnings } = sanitizeProfile(profile, { strict: true });
131
+ if (warnings.length > 0) {
132
+ const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
133
+ return json(res, 400, { ok: false, error: `写入被拒绝:${detail}` });
134
+ }
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;
141
+ const existing = deps.store.profiles.get(clean.id);
142
+ const seed = BUILTIN_SEEDS.find((s) => s.id === clean.id);
143
+ const isBuiltin = (existing !== undefined && existing.builtin === true) || seed !== undefined;
144
+ // Merge (not replace): start from the existing profile — or its seed when it
145
+ // was deleted — so fields not present in the form (e.g. a builtin's
146
+ // persona/preset) survive an edit or a re-add.
147
+ const merged = { ...(existing ?? seed ?? {}) };
148
+ merged.id = clean.id;
149
+ for (const key of ['name', 'description', 'preset', 'provider', 'model', 'reasoningEffort', 'persona', 'enabled']) {
150
+ if (clean[key] === undefined) continue; // 未传:保留 existing 原值
151
+ if (clean[key] === '' || clean[key] === null) { delete merged[key]; continue; } // 空:清除字段
152
+ merged[key] = clean[key];
153
+ }
154
+ // toolFilter 特殊处理:前端改成多选下拉后总是传数组,空数组 = 清除。请求未传
155
+ // toolFilter 时保留 existing 原值(merge 语义);传了但被 sanitize 归一为空则清除。
156
+ if (hadToolFilter) {
157
+ const tf = clean.toolFilter;
158
+ if (tf !== undefined && ((Array.isArray(tf.allow) && tf.allow.length > 0) || (Array.isArray(tf.deny) && tf.deny.length > 0))) {
159
+ merged.toolFilter = { ...(Array.isArray(tf.allow) && tf.allow.length > 0 ? { allow: tf.allow } : {}), ...(Array.isArray(tf.deny) && tf.deny.length > 0 ? { deny: tf.deny } : {}) };
160
+ } else {
161
+ delete merged.toolFilter;
162
+ }
163
+ }
164
+ if (hadTokenTier) merged.tokenTier = clean.tokenTier;
165
+ if (merged.enabled !== undefined) merged.enabled = merged.enabled === false ? false : true;
166
+ deps.store.profiles.set(merged.id, { ...merged, ...(isBuiltin ? { builtin: true } : {}), persisted: true });
167
+ deps.store.deletedBuiltins.delete(merged.id);
168
+ return persistOk(res, { id: merged.id }, deps.store.persistProfiles());
169
+ }
170
+
171
+ async function handleRemove(deps, req, res) {
172
+ const body = await readBody(req);
173
+ const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
174
+ const existing = deps.store.profiles.get(id);
175
+ if (existing === undefined) {
176
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
177
+ }
178
+ deps.store.profiles.delete(id);
179
+ if (existing.builtin === true) deps.store.deletedBuiltins.add(id);
180
+ return persistOk(res, { id }, deps.store.persistProfiles());
181
+ }
182
+
183
+ async function handleReset(deps, req, res) {
184
+ const body = await readBody(req);
185
+ const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
186
+ const seed = BUILTIN_SEEDS.find((s) => s.id === id);
187
+ if (seed === undefined) {
188
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" is not a builtin (nothing to reset)` });
189
+ }
190
+ deps.store.profiles.set(id, { ...seed });
191
+ deps.store.deletedBuiltins.delete(id);
192
+ return persistOk(res, { id }, deps.store.persistProfiles());
193
+ }
194
+
195
+ async function handleResetAll(deps, res) {
196
+ for (const seed of BUILTIN_SEEDS) {
197
+ deps.store.profiles.set(seed.id, { ...seed });
198
+ deps.store.deletedBuiltins.delete(seed.id);
199
+ }
200
+ return persistOk(res, { count: BUILTIN_SEEDS.length }, deps.store.persistProfiles());
201
+ }
202
+
203
+ async function handleSetProfileEnabled(deps, req, res) {
204
+ const body = await readBody(req);
205
+ const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
206
+ const existing = deps.store.profiles.get(id);
207
+ if (existing === undefined) {
208
+ return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
209
+ }
210
+ existing.enabled = body && body.enabled === false ? false : true;
211
+ // Persist unconditionally (not just for builtins): a runtime-registered
212
+ // profile's enable/disable must also survive a restart.
213
+ existing.persisted = true;
214
+ return persistOk(res, { id, enabled: existing.enabled }, deps.store.persistProfiles());
215
+ }
216
+
217
+ export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, catalog, logger }) {
218
+ const deps = { store, getEnabled, setEnabled, syncTool, catalog, logger };
219
+ // 路由分发(if 链保持,判断顺序与 404/500 兜底不变)。
220
+ const handler = async (req, res) => {
221
+ const remote = req.socket?.remoteAddress;
222
+ if (!LOOPBACKS.has(remote)) return json(res, 403, { ok: false, error: '仅限本机访问' });
223
+ const url = new URL(req.url ?? '/', 'http://localhost');
224
+ const sub = (url.pathname.replace(/^\/subagent-profiles/, '') || '/').replace(/\/+$/, '') || '/';
225
+ try {
226
+ if (req.method === 'GET' && (sub === '/' || sub === '/list')) return handleList(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);
232
+ if (req.method === 'POST' && sub === '/set-enabled') return handleSetEnabled(deps, req, res);
233
+ if (req.method === 'POST' && sub === '/add') return handleAdd(deps, req, res);
234
+ if (req.method === 'POST' && sub === '/remove') return handleRemove(deps, req, res);
235
+ if (req.method === 'POST' && sub === '/reset') return handleReset(deps, req, res);
236
+ if (req.method === 'POST' && sub === '/reset-all') return handleResetAll(deps, res);
237
+ if (req.method === 'POST' && sub === '/set-profile-enabled') return handleSetProfileEnabled(deps, req, res);
238
+ json(res, 404, { ok: false, error: `未知路由 ${sub}` });
239
+ } catch (error) {
240
+ // 通用 500 不回显内部错误信息(防泄漏),详情只进宿主日志。
241
+ deps.logger.error('[dsh-subagent-profile] settings route error:', error instanceof Error ? (error.stack ?? error.message) : String(error));
242
+ json(res, 500, { ok: false, error: '内部错误,详情见宿主日志' });
243
+ }
244
+ };
245
+ return () => {
246
+ const disposeRoutes = webServer.register({ kind: 'prefix', path: '/subagent-profiles', handler });
247
+ return () => disposeRoutes();
248
+ };
249
+ }