dsh-subagent-profile 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -1,527 +1,29 @@
1
- // index.mjs — dsh-subagent-profile host side (formal plugin bundle).
2
- // Converted from prototype/subagent-profile/host.plugin.js (the dynamic-plugin
3
- // `code.host` body) with import slimming: the inline foldConsumedWork /
4
- // accountsForClaim / lastAssistantContent / uuid / AbortController-shim are
5
- // replaced by package imports or platform primitives where semantics match
6
- // exactly, and the harness-only APIs (registerTool/defineTool/handle) are
7
- // mapped to their bundle equivalents or guarded. See the import mapping table
8
- // in README.md.
9
-
10
- import { randomUUID } from 'node:crypto';
11
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
12
- import { homedir } from 'node:os';
13
- import { basename, dirname, join, relative } from 'node:path';
14
- import { fileURLToPath } from 'node:url';
15
- import {
16
- appendDelegatedPolicyOverrides,
17
- assertSubagentMaxDepth,
18
- captureDelegatedPolicyOverrides,
19
- createUserMessage,
20
- defineTool,
21
- readResult,
22
- resolveChildAgentOptions,
23
- resolveChildDepth,
24
- } from './lib/shims.mjs';
25
- import { textFrom, stopReasonError, withPartialText, sanitizeProfile, GUIDANCE_PREFIX, assertHardLimits, computeContinuableAllow, pruneBlocks, assertResultSchemaConsistency } from './lib/pure.mjs';
1
+ // index.mjs — dsh-subagent-profile 宿主侧正式插件 bundle(只做装配)。
2
+ // 源自原型动态插件 `code.host` 主体;宿主导入面(registerTool/defineTool/
3
+ // handle)收敛在 lib/core/shims.mjs,装配块按模块拆分驻留 lib/core/(catalog /
4
+ // presets-sync / profiles-store / cost-guard / whitelist / intersection /
5
+ // delegation / pure / shims / http-routes / profile-provider / dispatch-tool)。
6
+ // apply 的装配辅助函数(syncBundledPresetsToHome / provideProfileService /
7
+ // registerSystemPromptSections / registerSettingsRoutes)保持 section 文本与
8
+ // 门控逐字不变;`enabled` 始终经 getter 注入,门控读取当前值。
9
+
10
+ import { join } from 'node:path';
11
+ import { syncBundledPresets } from './lib/core/presets-sync.mjs';
12
+ import { dshHome, createProfileStore } from './lib/core/profiles-store.mjs';
13
+ import { createHttpRoutes } from './lib/core/http-routes.mjs';
14
+ import { createProfileProvider } from './lib/core/profile-provider.mjs';
15
+ import { createDispatchTool } from './lib/core/dispatch-tool.mjs';
26
16
 
27
17
  export const name = 'dsh-subagent-profile';
28
18
  export const inject = ['subagents', 'tools', 'agents'];
29
19
 
30
- // Resolve the DSH home directory (env override wins, platform fallback) — the
31
- // same policy as the dsh-persona-ref bundle: user profiles persist to
32
- // ~/.dsh/subagent-profiles.json, stable across harness working directories.
33
- function dshHome() {
34
- const raw = process.env.DSH_HOME;
35
- if (typeof raw === 'string' && raw.trim() !== '') {
36
- const trimmed = raw.trim();
37
- if (trimmed === '~') return homedir();
38
- if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) return join(homedir(), trimmed.slice(2));
39
- return trimmed;
40
- }
41
- return join(homedir(), '.dsh');
42
- }
43
-
44
- // --- bundled agent-preset self-install --------------------------------------
45
- // On host startup the plugin syncs the bundled `presets/` tree into the DSH
46
- // agent-presets discovery root (~/.dsh/.agent-presets) so the "orchestrator"
47
- // mode appears in the new-session picker without manual copying — the same
48
- // self-install pattern as the shipped dsh-liangshen bundle. The sync is
49
- // per-directory and idempotent (byte-identical trees are skipped; target files
50
- // the bundle no longer ships are pruned); directories the plugin does not own
51
- // are never touched. node:fs cpSync is avoided deliberately: on Node 22 for
52
- // Windows, fs.cpSync({ recursive: true }) can crash the process when a source
53
- // path contains non-ASCII (CJK home dir, nodejs/node#54476), so the copy is
54
- // per-entry, preserving source mtimes.
55
-
56
- // Absolute path of the bundled preset tree inside this package.
57
- function bundledPresetsRoot() {
58
- return join(dirname(fileURLToPath(import.meta.url)), 'presets');
59
- }
60
-
61
- const MTIME_TOLERANCE_MS = 1000;
62
-
63
- function filesUnder(root) {
64
- const out = [];
65
- const walk = (dir) => {
66
- for (const entry of readdirSync(dir)) {
67
- const path = join(dir, entry);
68
- if (statSync(path).isDirectory()) walk(path);
69
- else out.push(path);
70
- }
71
- };
72
- walk(root);
73
- return out;
74
- }
75
-
76
- // File identity is bytes; size/mtime are only a fast negative check.
77
- function sameFile(a, b) {
78
- const sa = statSync(a);
79
- const sb = statSync(b);
80
- if (sa.size !== sb.size) return false;
81
- if (Math.abs(sa.mtimeMs - sb.mtimeMs) > MTIME_TOLERANCE_MS) return false;
82
- return readFileSync(a).equals(readFileSync(b));
83
- }
84
-
85
- function copyTreeSync(sourceDir, targetDir) {
86
- mkdirSync(targetDir, { recursive: true });
87
- for (const entry of readdirSync(sourceDir)) {
88
- const source = join(sourceDir, entry);
89
- const target = join(targetDir, entry);
90
- const st = statSync(source);
91
- if (st.isDirectory()) copyTreeSync(source, target);
92
- else {
93
- copyFileSync(source, target);
94
- utimesSync(target, st.atime, st.mtime);
95
- }
96
- }
97
- }
98
-
99
- // Remove target files not in `keep`, then only the directories emptied by it.
100
- function pruneExtras(root, keep) {
101
- const parents = new Set();
102
- for (const file of filesUnder(root)) {
103
- if (!keep.has(relative(root, file))) {
104
- parents.add(dirname(file));
105
- rmSync(file, { force: true });
106
- }
107
- }
108
- for (const start of parents) {
109
- let dir = start;
110
- while (dir !== undefined && relative(root, dir) !== '') {
111
- if (existsSync(dir) && readdirSync(dir).length === 0) {
112
- rmSync(dir, { recursive: true, force: true });
113
- dir = dirname(dir);
114
- } else dir = undefined;
115
- }
116
- }
117
- }
118
-
119
- // Copy `sourceDir` into `targetDir` idempotently; returns 'synced' or 'current'.
120
- function syncOnePreset(sourceDir, targetDir) {
121
- const sourceFiles = filesUnder(sourceDir);
122
- const sourceSet = new Set(sourceFiles.map((f) => relative(sourceDir, f)));
123
- if (existsSync(targetDir) && !statSync(targetDir).isDirectory()) {
124
- rmSync(targetDir, { recursive: true, force: true });
125
- }
126
- if (!existsSync(targetDir)) {
127
- copyTreeSync(sourceDir, targetDir);
128
- pruneExtras(targetDir, sourceSet);
129
- return 'synced';
130
- }
131
- let dirty = false;
132
- for (const file of sourceFiles) {
133
- const dest = join(targetDir, relative(sourceDir, file));
134
- if (!existsSync(dest) || !sameFile(file, dest)) { dirty = true; break; }
135
- }
136
- if (!dirty) {
137
- for (const file of filesUnder(targetDir)) {
138
- if (!sourceSet.has(relative(targetDir, file))) { dirty = true; break; }
139
- }
140
- }
141
- if (!dirty) return 'current';
142
- pruneExtras(targetDir, sourceSet);
143
- copyTreeSync(sourceDir, targetDir);
144
- pruneExtras(targetDir, sourceSet);
145
- return 'synced';
146
- }
147
-
148
- // Sync every preset directory under `presets/` into the target discovery root.
149
- function syncBundledPresets(targetRoot) {
150
- const result = { synced: [], current: [], failed: [] };
151
- const sourceRoot = bundledPresetsRoot();
152
- mkdirSync(targetRoot, { recursive: true });
153
- if (existsSync(sourceRoot)) {
154
- for (const entry of readdirSync(sourceRoot)) {
155
- const source = join(sourceRoot, entry);
156
- if (!statSync(source).isDirectory()) continue;
157
- const id = basename(source);
158
- try {
159
- const outcome = syncOnePreset(source, join(targetRoot, id));
160
- (outcome === 'synced' ? result.synced : result.current).push(id);
161
- } catch (error) {
162
- result.failed.push({ id, error: error instanceof Error ? error.message : String(error) });
163
- }
164
- }
165
- }
166
- return result;
167
- }
168
-
169
- // Only the loopback interfaces may drive the settings HTTP routes.
170
- const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
171
-
172
- // The plugin's enable/disable switch. Persisted beside the profile list so a
173
- // user can turn the dispatch tool off without uninstalling the bundle. Default
174
- // is enabled; a missing/unreadable file falls back to enabled.
175
- function stateFile() {
176
- return join(dshHome(), 'subagent-profiles.state.json');
177
- }
178
- function loadEnabled() {
179
- try {
180
- if (existsSync(stateFile())) {
181
- const parsed = JSON.parse(readFileSync(stateFile(), 'utf8'));
182
- return parsed && parsed.enabled !== false;
183
- }
184
- } catch {
185
- // fall through to enabled
186
- }
187
- return true;
188
- }
189
- function persistEnabled(enabled) {
190
- try {
191
- writeFileSync(stateFile(), JSON.stringify({ enabled }, null, 2), 'utf8');
192
- } catch {
193
- // best effort — the in-memory state still drives this process
194
- }
195
- }
196
-
197
- // --- module-level helpers (kept inline; the packages do not export them) ---
198
-
199
- // F5: runtime-derived cost guard. Two parts (SPEC §7.3):
200
- // ① always-on hard caps (assertHardLimits, in lib/pure.mjs) — maxTokens /
201
- // maxDepth are hard delegation caps, independent of the `llm` service, so
202
- // they must NOT stop applying when `llm` is absent (the old `if (llm ===
203
- // undefined) return` skipped them).
204
- // ② llm capability — validates provider / model / reasoningEffort against the
205
- // live provider directory. When the `llm` service is absent OR its provider
206
- // directory is empty (an adapter without discovery), capability cannot be
207
- // verified: per `allowFailOpen` (SPEC §12.1 migration switch) either
208
- // fail-open compat (warn + skip; v1 数据迁移中) or fail-loud reject. A
209
- // profile that requests none of provider/model/reasoningEffort has nothing
210
- // to verify and always passes (valid in a headless deployment).
211
- // Used by both the provider's authoritative check and the dispatch tool's
212
- // pre-check. `allowFailOpen`/`logger` are injected because this function is
213
- // module-scoped and cannot reach the apply closure's `allowFailOpen`/`ctx.logger`.
214
- async function assertCostGuard(parent, profile, allowFailOpen, logger) {
215
- // ① 硬上限 always-on(不依赖 llm)。
216
- assertHardLimits(profile.maxTokens, profile.maxDepth);
217
-
218
- // ② 仅当 profile 请求了需核验能力面的字段时才进入 llm 校验(无头/headless 部署下
219
- // persona-only / toolFilter-only 的 profile 合法,不应被 fail-loud 拒绝)。
220
- const needsLlm = ['provider', 'model', 'reasoningEffort'].some(
221
- (key) => typeof profile[key] === 'string' && profile[key].length > 0
222
- );
223
- if (!needsLlm) return;
224
-
225
- const llm = parent.ctx.get('llm');
226
- // ③ 目录为空检测:llm 存在但其 provider 目录为空(无发现能力)→ 无法核验。
227
- let emptyDirectory = false;
228
- if (llm !== undefined) {
229
- try {
230
- const providers = await llm.listProviders();
231
- emptyDirectory = (providers ?? []).length === 0;
232
- } catch {
233
- emptyDirectory = true;
234
- }
235
- }
236
- if (llm === undefined || emptyDirectory) {
237
- if (allowFailOpen === true) {
238
- logger.warn('llm 不可用:fail-open 兼容模式(v1 数据迁移中,建议保存一次配置以升级到 fail-loud)');
239
- return;
240
- }
241
- throw new Error('dispatch: 模型能力不可验证:fail-loud 拒绝(可在配置中显式开启兼容模式)');
242
- }
243
-
244
- // ④ provider 注册校验(目录非空时才能判定「不在目录」)。
245
- if (typeof profile.provider === 'string' && profile.provider.length > 0) {
246
- const providers = await llm.listProviders();
247
- if (!(providers ?? []).some((provider) => provider && provider.id === profile.provider)) {
248
- throw new Error(`dispatch: provider "${profile.provider}" is not a registered provider`);
249
- }
250
- }
251
- const effectiveProvider = profile.provider !== undefined ? profile.provider : parent.options.provider;
252
- const effectiveModel = profile.model !== undefined ? profile.model : parent.options.model;
253
- // ⑤ model 校验。resolveModelInfo does not reject unknown models (catalog
254
- // membership is advisory), so validate against the advertised catalog
255
- // instead. An EMPTY catalog (adapter without discovery) cannot be verified
256
- // and is skipped — 目录级空集已在 ③ 走 allowFailOpen 分支,此处仅兜底
257
- // per-provider 空目录。A non-empty catalog that does not advertise the model
258
- // fails loud. An unverifiable lookup (listModels(undefined) when no provider
259
- // is known) becomes a clean fail-loud error instead of leaking "undefined".
260
- if (typeof profile.model === 'string' && profile.model.length > 0) {
261
- let models;
262
- try {
263
- models = await llm.listModels(effectiveProvider);
264
- } catch (error) {
265
- throw new Error(`dispatch: cannot validate model "${profile.model}" without a provider: ${error instanceof Error ? error.message : String(error)}`);
266
- }
267
- const listed = models ?? [];
268
- const known = listed.length > 0 && listed.some((model) => model && (model.id === profile.model || model.name === profile.model));
269
- if (listed.length > 0 && !known) {
270
- throw new Error(`dispatch: model "${profile.model}" is not advertised by provider "${String(effectiveProvider)}"`);
271
- }
272
- }
273
- // ⑥ reasoningEffort 校验。
274
- if (typeof profile.reasoningEffort === 'string' && profile.reasoningEffort.length > 0) {
275
- try {
276
- await llm.resolveCallConfig({ provider: effectiveProvider, model: effectiveModel, reasoningEffort: profile.reasoningEffort });
277
- } catch (error) {
278
- throw new Error(`dispatch: reasoningEffort "${profile.reasoningEffort}" is not supported by provider "${String(effectiveProvider)}" model "${String(effectiveModel)}": ${error instanceof Error ? error.message : String(error)}`);
279
- }
280
- }
281
- }
282
-
283
- // Settle one background one-shot run into a job outcome with the same
284
- // observability metadata the foreground path reports. Non-completed stop reasons
285
- // become failed (aborted => killed, shipped vocabulary) with partial output
286
- // attached; hard failures never reject the job.
287
- // `prune` is the result-recycle pre-clipper (§8.2): the caller (dispatch
288
- // execute) injects a closure that calls the host toolResultPruner.pruneContent
289
- // before textFrom; defaulting to identity keeps the background path safe when no
290
- // pruner is available.
291
- async function settleStart(start, signal, meta, prune = (blocks) => blocks) {
292
- let run;
293
- try {
294
- run = await start;
295
- const result = await run.result;
296
- const failure = stopReasonError(result);
297
- if (failure !== undefined) {
298
- return { status: result.stopReason === 'aborted' ? 'killed' : 'failed', detail: withPartialText(failure, result.output), ...meta };
299
- }
300
- return { status: 'completed', output: textFrom(prune(result.output)), ...meta };
301
- } catch (error) {
302
- return signal.aborted ? { status: 'killed', ...meta } : { status: 'failed', detail: String(error), ...meta };
303
- } finally {
304
- // Release the child handle no matter how the result settled — run.result
305
- // rejecting must not leak the subagent (same discipline as the foreground
306
- // try/finally).
307
- if (run !== undefined) await run.dispose().catch(() => {});
308
- }
309
- }
310
-
311
- // Verbatim from the shipped SUBAGENT_DELEGATION_CONTEXT.
312
- const DELEGATION_CONTEXT = 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.';
313
-
314
- export async function apply(ctx) {
315
- // 0. Enable/disable switch: default on, toggled at runtime by the settings
316
- // page and persisted across restarts. When off, the dispatch tool is
317
- // unregistered so it disappears from the model's tool list.
318
- let enabled = loadEnabled();
319
-
320
- // 1. Profile registry (per-instance state; a bundle row is process-level, so
321
- // this Map is the singleton store, exactly like the dynamic plugin's).
322
- // Builtin seeds carry `builtin: true` so reset/remove can identify them and
323
- // a modified builtin stays distinguishable from a pure user profile.
324
- // Descriptions are semantic: one-line positioning + when to use, to help
325
- // the model choose. reasoningEffort levels verified against the
326
- // llm-deepseek adapter (off/high/max; see resolveModel gating on
327
- // connection.defaults.thinking — this deployment leaves thinking unset,
328
- // so the full set is advertised for deepseek-v4-flash).
329
- const BUILTIN_SEEDS = [
330
- { id: 'swap-standard', name: '标准编码', description: '切换到 standard 预设的完整编码工具集。当父会话不是 standard、但子任务需要完整编码能力时用。', preset: 'standard', builtin: true },
331
- { 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 }
332
- ];
333
-
334
- // Tool-name → 中文说明 map, shown beside the raw tool name in the toolFilter
335
- // picker. Tools absent here fall back to their raw name.
336
- const TOOL_ZH = {
337
- 'bash': '终端命令',
338
- 'pwsh': 'PowerShell 命令',
339
- 'read': '读取文件',
340
- 'write': '写入文件',
341
- 'edit': '编辑文件',
342
- 'grep': '搜索文件内容',
343
- 'glob': '查找文件',
344
- 'web_search': '网页搜索',
345
- 'browser_navigate': '浏览器打开网址',
346
- 'browser_snapshot': '浏览器页面快照',
347
- 'browser_click': '浏览器点击',
348
- 'browser_type': '浏览器输入',
349
- 'browser_scroll': '浏览器滚动',
350
- 'browser_back': '浏览器后退',
351
- 'browser_forward': '浏览器前进',
352
- 'browser_press': '浏览器按键',
353
- 'browser_reload': '浏览器刷新',
354
- 'browser_wait': '浏览器等待',
355
- 'browser_get_text': '读取页面文本',
356
- 'dispatch': '派发子 Agent',
357
- 'subagent': '派生子 Agent',
358
- 'subagent_fork': '派生子 Agent(继承上下文)',
359
- 'send_message': '给子 Agent 发消息',
360
- 'interrupt_agent': '中断子 Agent',
361
- 'list_agents': '列出子 Agent',
362
- 'todo_write': '任务清单',
363
- 'create_goal': '创建目标',
364
- 'get_goal': '查看目标',
365
- 'update_goal': '更新目标',
366
- 'workflow': '编排多 Agent 工作流',
367
- 'ralph': 'Ralph 迭代',
368
- 'ask_user_question': '询问用户',
369
- 'skill': '加载技能',
370
- 'describe_image': '描述图片',
371
- 'read_image': '读取图片',
372
- 'modlens_read_image': '读取图片(modlens)',
373
- 'ssh_list': '列出 SSH 主机',
374
- 'ssh_exec': 'SSH 执行命令',
375
- 'ssh_upload': 'SSH 上传',
376
- 'ssh_download': 'SSH 下载',
377
- 'ssh_tunnel': 'SSH 隧道',
378
- 'ssh_cluster': 'SSH 集群执行',
379
- 'exit_plan_mode': '退出计划模式',
380
- 'incident_resolved': '标记事故已解决',
381
- 'dsh_rollback': '回滚 DSH',
382
- 'dsh_snapshot': 'DSH 快照',
383
- 'job_list': '列出后台任务',
384
- 'job_output': '读取后台任务输出',
385
- 'job_kill': '终止后台任务',
386
- 'str_replace_editor': '文本编辑',
387
- 'cordis_inspect_list': '列出 Cordis 服务',
388
- 'cordis_inspect_query': '查询 Cordis 服务',
389
- 'cordis_inspect_self': '查看自身 Cordis 服务',
390
- 'cordis_define': '定义 Cordis 服务',
391
- 'cordis_run': '运行 Cordis 服务',
392
- 'cordis_stop': '停止 Cordis 服务',
393
- 'cordis_undefine': '取消定义 Cordis 服务',
394
- 'run_code': '运行代码',
395
- };
396
-
397
- // Tool-name → 功能分类 map,覆盖 DSH 官方核心工具(固定集合)。插件工具
398
- // 走前缀提取(见 categoryOf),自建预设用 preset 名。
399
- const TOOL_CATEGORY = {
400
- 'read': '文件', 'write': '文件', 'edit': '文件', 'grep': '文件', 'glob': '文件', 'str_replace_editor': '文件',
401
- 'bash': '终端', 'pwsh': '终端',
402
- 'web_search': '网络',
403
- 'todo_write': '任务', 'create_goal': '任务', 'get_goal': '任务', 'update_goal': '任务',
404
- 'subagent': '子 Agent', 'subagent_fork': '子 Agent', 'send_message': '子 Agent', 'interrupt_agent': '子 Agent', 'list_agents': '子 Agent',
405
- 'workflow': '工作流', 'ralph': '工作流',
406
- 'ask_user_question': '交互', 'skill': '交互',
407
- 'read_image': '图片', 'describe_image': '图片',
408
- 'cordis_inspect_list': 'Cordis', 'cordis_inspect_query': 'Cordis', 'cordis_inspect_self': 'Cordis',
409
- 'cordis_define': 'Cordis', 'cordis_run': 'Cordis', 'cordis_stop': 'Cordis', 'cordis_undefine': 'Cordis',
410
- 'exit_plan_mode': '计划',
411
- };
412
-
413
- const profiles = new Map(BUILTIN_SEEDS.map((p) => [p.id, { ...p }]));
414
-
415
- // Builtin ids the user has deleted (soft delete via persisted tombstone). The
416
- // Map keeps working entries; this Set records tombstones so they survive
417
- // restarts and a later reset can clear them.
418
- const deletedBuiltins = new Set();
419
-
420
- // F6: the target-preset whitelist is derived from the runtime roster, not
421
- // hard-coded: system-trust presets when agentPresets exists, else the
422
- // shipped fallback names.
423
- const FALLBACK_WHITELIST = ['standard', 'code', 'minimal'];
424
- async function resolveWhitelist(agentCtx) {
425
- const agentPresets = agentCtx.get('agentPresets');
426
- if (agentPresets === undefined) return FALLBACK_WHITELIST;
427
- const presets = await agentPresets.list();
428
- return (presets ?? []).filter((preset) => preset && preset.trust === 'system').map((preset) => preset.id);
429
- }
430
-
431
- function resolveProfile(id) {
432
- const found = profiles.get(id);
433
- if (found === undefined) throw new Error(`dispatch: unknown profile "${id}"`);
434
- if (found.enabled === false) throw new Error(`dispatch: profile "${id}" is disabled`);
435
- return found;
436
- }
437
-
438
- // 1b. User profile persistence. The settings service cannot serve this
439
- // plugin (its write path hard-requires register(ns, schema) + a schemastery
440
- // schema), so user profiles are persisted to ~/.dsh/subagent-profiles.json
441
- // through node:fs — a bundle has node globals, unlike the dynamic-plugin
442
- // sandbox that needed the optional `fs` service. Loaded once at startup; the
443
- // add/remove HTTP routes rewrite the file. Persistence is an enhancement, not
444
- // a hard dependency: any failure only warns and the builtin seeds work.
445
- const profilesFile = join(dshHome(), 'subagent-profiles.json');
446
- // V2 migration switch (SPEC §7.3 / §12.1): whether the cost guard may fail-open
447
- // when the `llm` service is absent. Defaults to FALSE (fail-loud, SPEC §7.3
448
- // 评审遗留裁定: 全新部署 fail-loud); only a v1 data file being read sets it to
449
- // true (v1 迁移 fail-open 兼容). A v2 envelope carries its own stored value.
450
- // Task 4 (cost-guard narrow) reads this flag.
451
- let allowFailOpen = false;
452
- function loadProfiles() {
453
- if (!existsSync(profilesFile)) return;
454
- try {
455
- const parsed = JSON.parse(readFileSync(profilesFile, 'utf8'));
456
- // Two file shapes (SPEC §12.1): v1 = a bare array; v2 = { version:2,
457
- // profiles:[...], allowFailOpen:<bool> }.
458
- let entries;
459
- let version;
460
- if (Array.isArray(parsed)) {
461
- entries = parsed;
462
- version = 1;
463
- } else if (parsed !== null && typeof parsed === 'object' && Array.isArray(parsed.profiles)) {
464
- entries = parsed.profiles;
465
- version = parsed.version ?? 2;
466
- // v2 缺 allowFailOpen 字段时按 fail-open 兼容(显式 false 才关闭);
467
- // 全新部署默认已定:fail-loud(初始 allowFailOpen=false,见上方声明),
468
- // 读入 v2 文件按其存储值读取并保持。
469
- allowFailOpen = parsed.allowFailOpen !== false;
470
- } else {
471
- ctx.logger.warn('[dsh-subagent-profile] persisted profile file has an unrecognized shape; ignoring');
472
- return;
473
- }
474
- // v1 (or an unversioned array): migrate in memory, keep fail-open compat.
475
- if (version !== 2) {
476
- allowFailOpen = true;
477
- ctx.logger.warn('[dsh-subagent-profile] v1 数据:fail-open 兼容模式');
478
- }
479
- let loaded = 0;
480
- let skipped = 0;
481
- for (const raw of entries) {
482
- if (!raw || typeof raw !== 'object' || typeof raw.id !== 'string' || raw.id.length === 0) {
483
- skipped++;
484
- ctx.logger.warn('[dsh-subagent-profile] skipping malformed profile entry:', raw === null ? String(raw) : typeof raw);
485
- continue;
486
- }
487
- // Per-entry sanitize (strict=false): over-limit fields are dropped
488
- // (field removed) + warned; an over-length persona is KEPT + warned
489
- // (never silently truncated). A bad entry is skipped (fail-soft).
490
- const { clean, warnings } = sanitizeProfile(raw, { strict: false });
491
- for (const warning of warnings) {
492
- ctx.logger.warn(`[dsh-subagent-profile] profile "${raw.id}" ${warning.field} 被跳过或提示:${warning.reason}`);
493
- }
494
- if (clean.deleted === true) {
495
- if (clean.builtin === true) {
496
- profiles.delete(clean.id);
497
- deletedBuiltins.add(clean.id);
498
- }
499
- continue;
500
- }
501
- const existing = profiles.get(clean.id);
502
- if (existing !== undefined && existing.builtin === true) {
503
- profiles.set(clean.id, { ...clean, builtin: true, persisted: true });
504
- } else {
505
- profiles.set(clean.id, { ...clean, persisted: true });
506
- }
507
- loaded++;
508
- }
509
- // Silent success: report how many persisted profiles came in (skipped
510
- // when none — a missing/empty file is the normal first boot).
511
- if (loaded > 0) ctx.logger.info(`[dsh-subagent-profile] loaded ${loaded} persisted profile(s)`);
512
- if (skipped > 0) ctx.logger.warn(`[dsh-subagent-profile] skipped ${skipped} malformed profile entry(ies)`);
513
- } catch (error) {
514
- ctx.logger.warn('[dsh-subagent-profile] persisted profile load failed:', error instanceof Error ? error.message : String(error));
515
- }
516
- }
517
- loadProfiles();
518
-
519
- // 1c. Self-install the bundled "orchestrator" agent preset into the DSH
520
- // agent-presets root so the mode appears in the new-session picker without
521
- // manual copying (mirrors the shipped dsh-liangshen self-install). Idempotent:
522
- // byte-identical trees are skipped; a bundle change rewrites the preset — the
523
- // intended upgrade path. Fail-soft: the dispatch tool and settings page keep
524
- // working even if the write is denied.
20
+ // Self-install the bundled "orchestrator" agent preset into the DSH
21
+ // agent-presets root so the mode appears in the new-session picker without
22
+ // manual copying (mirrors the shipped dsh-liangshen self-install). Idempotent:
23
+ // byte-identical trees are skipped; a bundle change rewrites the preset — the
24
+ // intended upgrade path. Fail-soft: the dispatch tool and settings page keep
25
+ // working even if the write is denied.
26
+ function syncBundledPresetsToHome(ctx) {
525
27
  try {
526
28
  const presetRoot = join(dshHome(), '.agent-presets');
527
29
  const sync = syncBundledPresets(presetRoot);
@@ -530,897 +32,169 @@ export async function apply(ctx) {
530
32
  } catch (error) {
531
33
  ctx.logger.warn('[dsh-subagent-profile] preset sync failed:', error instanceof Error ? error.message : String(error));
532
34
  }
35
+ }
533
36
 
534
- /**
535
- * Persist every `persisted: true` profile plus builtin-delete tombstones.
536
- * Atomic write (SPEC §12.2): write `<profilesFile>.tmp` in the same directory,
537
- * then `renameSync` over the target (a crash leaves the old file intact, never
538
- * a truncated one). Fail-visible (D5/B1): a failure does NOT throw and does NOT
539
- * roll back the in-memory `profiles` Map — it returns `{ persisted: false }` so
540
- * the caller can signal "已保存但未持久化" while the in-memory state keeps
541
- * driving this process. Always writes the v2 envelope shape.
542
- */
543
- function persistProfiles() {
544
- const entries = [];
545
- for (const profile of profiles.values()) {
546
- if (profile.persisted !== true) continue;
547
- const clean = {};
548
- for (const [key, value] of Object.entries(profile)) {
549
- if (value === undefined || key === 'persisted') continue;
550
- clean[key] = value;
551
- }
552
- entries.push(clean);
553
- }
554
- for (const id of deletedBuiltins) {
555
- entries.push({ id, builtin: true, deleted: true });
556
- }
557
- const payload = { version: 2, profiles: entries, allowFailOpen };
558
- const tmp = `${profilesFile}.tmp`;
559
- try {
560
- writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
561
- renameSync(tmp, profilesFile);
562
- return { persisted: true };
563
- } catch (error) {
564
- // Best-effort cleanup of the partial tmp file (rename never ran).
565
- try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
566
- ctx.logger.warn('[dsh-subagent-profile] persisted profile write failed:', error instanceof Error ? error.message : String(error));
567
- return { persisted: false, error: error instanceof Error ? error.message : String(error) };
568
- }
569
- }
570
-
571
- // 1c. HTTP loopback routes for the Client settings UI (webServer.register ↔
572
- // client fetch; JSON only). webServer is optional — a headless deployment
573
- // keeps the dispatch tool and drops only the settings page. webServer's
574
- // activation (listen) is async and may not be ready when this plugin's
575
- // inject deps resolve, so register inside an inject sub-scope that waits for
576
- // it (ctx.get would read undefined at apply time).
577
- ctx.inject(['webServer'], (scope) => {
578
- const json = (res, code, data) => {
579
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
580
- res.end(JSON.stringify(data));
581
- };
582
- const readBody = (req) => new Promise((resolve, reject) => {
583
- let data = '';
584
- let size = 0;
585
- req.on('data', (chunk) => {
586
- size += chunk.length;
587
- if (size > 1 << 20) { reject(new Error('请求体过大')); req.destroy(); return; }
588
- data += chunk;
589
- });
590
- req.on('end', () => {
591
- try { resolve(data === '' ? {} : JSON.parse(data)); } catch { reject(new Error('请求体不是合法 JSON')); }
592
- });
593
- req.on('error', reject);
594
- });
595
- // D5/B1 write-failure contract: the write routes return HTTP 200 with
596
- // `persisted` always present; when the disk write failed, persistWarning
597
- // explains "已保存但未持久化" (in-memory state drives this process, the
598
- // disk did not update). The client renders that as the amber warning.
599
- const persistOk = (res, payload, persist) => json(res, 200, {
600
- ok: true,
601
- ...payload,
602
- persisted: persist.persisted,
603
- ...(persist.persisted ? {} : { persistWarning: '已保存但未持久化' }),
604
- });
605
- const listClean = () => [...profiles.values()].map((profile) => {
606
- const clean = {};
607
- for (const [key, value] of Object.entries(profile)) if (value !== undefined && key !== 'persisted') clean[key] = value;
608
- // The internal `persisted` flag is stripped above; expose a UI-facing
609
- // "modified" signal so the reset panel can label a changed builtin.
610
- if (profile.builtin === true && profile.persisted === true) clean.modified = true;
611
- return clean;
612
- });
613
- const handler = async (req, res) => {
614
- const remote = req.socket?.remoteAddress;
615
- if (!LOOPBACKS.has(remote)) return json(res, 403, { ok: false, error: '仅限本机访问' });
616
- const url = new URL(req.url ?? '/', 'http://localhost');
617
- const sub = (url.pathname.replace(/^\/subagent-profiles/, '') || '/').replace(/\/+$/, '') || '/';
618
- try {
619
- if (req.method === 'GET' && (sub === '/' || sub === '/list')) {
620
- return json(res, 200, { ok: true, profiles: listClean() });
621
- }
622
- if (req.method === 'GET' && sub === '/options') {
623
- const models = [];
624
- const efforts = {};
625
- const presets = [];
626
- // Model directory + per-model reasoning-effort levels. The `llm`
627
- // service is optional (headless): a failure only empties the lists,
628
- // never breaks the settings page.
629
- const llm = ctx.get('llm');
630
- if (llm !== undefined) {
631
- try {
632
- const providers = await llm.listProviders();
633
- for (const provider of (providers ?? [])) {
634
- const providerId = provider && provider.id;
635
- if (typeof providerId !== 'string') continue;
636
- let modelList = [];
637
- try { modelList = await llm.listModels(providerId); } catch { /* skip this provider's catalog */ }
638
- for (const model of (modelList ?? [])) {
639
- if (!model || typeof model.id !== 'string') continue;
640
- models.push({
641
- provider: providerId,
642
- providerName: provider.name ?? providerId,
643
- id: model.id,
644
- name: model.name ?? model.id
645
- });
646
- try {
647
- const info = await llm.resolveModelInfo(providerId, model.id);
648
- const effortsList = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : [];
649
- efforts[model.id] = effortsList.map((effort) => ({
650
- id: effort.id,
651
- name: effort.name ?? effort.id,
652
- ...(effort.description !== undefined ? { description: effort.description } : {})
653
- }));
654
- } catch { /* exact-model lookup may reject; skip its efforts */ }
655
- }
656
- }
657
- } catch { /* llm directory unavailable; leave options empty */ }
658
- }
659
- // System-trust presets (agentPresets is optional; fail-soft).
660
- const agentPresets = ctx.get('agentPresets');
661
- if (agentPresets !== undefined) {
662
- try {
663
- const list = await agentPresets.list();
664
- for (const preset of (list ?? [])) {
665
- if (preset && preset.trust === 'system') {
666
- presets.push({ id: preset.id, name: preset.name ?? preset.id });
667
- }
668
- }
669
- } catch { /* presets roster unavailable; leave empty */ }
670
- }
671
- // Full tool directory = global layer (deployment plugins) + every
672
- // preset's standing scope (the agent.cordis.yml tool rows). Each tool
673
- // is tagged with its source: 'global' or the preset id — the grouping
674
- // is fully dynamic, derived from the runtime's preset roster.
675
- let tools = [];
676
- try {
677
- const seen = new Set();
678
- const OFFICIAL_PRESETS = ['standard', 'code', 'minimal', 'cordis'];
679
- const layerOf = (source) => {
680
- if (source === 'global') return 'plugin';
681
- if (OFFICIAL_PRESETS.includes(source)) return 'core';
682
- return 'custom';
683
- };
684
- const groupOf = (name, source) => {
685
- const layer = layerOf(source);
686
- if (layer === 'core') return TOOL_CATEGORY[name] ?? '其他';
687
- if (layer === 'plugin') return name.includes('_') ? name.split('_')[0] : name;
688
- return source;
689
- };
690
- const push = (schemas, source) => {
691
- for (const s of (Array.isArray(schemas) ? schemas : [])) {
692
- if (!s || typeof s.name !== 'string' || s.name === 'run_code' || seen.has(s.name)) continue;
693
- seen.add(s.name);
694
- 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) });
695
- }
696
- };
697
- if (ctx.tools && typeof ctx.tools.schemas === 'function') {
698
- push(ctx.tools.schemas(), 'global');
699
- const agentPresets = ctx.get('agentPresets');
700
- if (agentPresets !== undefined && typeof agentPresets.list === 'function' && typeof agentPresets.standingKeyFor === 'function') {
701
- const presets = await agentPresets.list();
702
- for (const preset of (presets ?? [])) {
703
- if (!preset || typeof preset.id !== 'string') continue;
704
- try {
705
- push(ctx.tools.schemas(await agentPresets.standingKeyFor(preset.id)), preset.id);
706
- } catch { /* one preset's standing scope unavailable; skip */ }
707
- }
708
- }
709
- }
710
- } catch (error) {
711
- ctx.logger.warn('[dsh-subagent-profile] tools directory failed:', error instanceof Error ? error.message : String(error));
712
- }
713
- return json(res, 200, { ok: true, enabled, models, efforts, presets, tools });
714
- }
715
- if (req.method === 'POST' && sub === '/set-enabled') {
716
- const body = await readBody(req);
717
- const next = !!(body && body.enabled === true);
718
- enabled = next;
719
- persistEnabled(next);
720
- syncTool();
721
- return json(res, 200, { ok: true, enabled });
722
- }
723
- if (req.method === 'POST' && sub === '/add') {
724
- const body = await readBody(req);
725
- const profile = body && typeof body === 'object' ? body : {};
726
- if (typeof profile.id !== 'string' || profile.id.length === 0) {
727
- return json(res, 400, { ok: false, error: 'subagent-profiles: profile id must be a non-empty string' });
728
- }
729
- // 写路径上限(SPEC §7.2):strict=true —— 超限/非法字段直接 400 拒绝,
730
- // 与 loadProfiles(strict=false 迁移宽松读取)的行为区分。列被拒字段与中文原因。
731
- const { clean, warnings } = sanitizeProfile(profile, { strict: true });
732
- if (warnings.length > 0) {
733
- const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
734
- return json(res, 400, { ok: false, error: `写入被拒绝:${detail}` });
735
- }
736
- const hadToolFilter = profile.toolFilter !== undefined;
737
- const existing = profiles.get(clean.id);
738
- const seed = BUILTIN_SEEDS.find((s) => s.id === clean.id);
739
- const isBuiltin = (existing !== undefined && existing.builtin === true) || seed !== undefined;
740
- // Merge (not replace): start from the existing profile — or its seed
741
- // when it was deleted — so fields not present in the form (e.g. a
742
- // builtin's persona/preset) survive an edit or a re-add. `clean` is the
743
- // sanitized request body, so a field absent from the request is absent
744
- // from clean and the existing value is preserved (merge semantics).
745
- const merged = { ...(existing ?? seed ?? {}) };
746
- merged.id = clean.id;
747
- for (const key of ['name', 'description', 'preset', 'provider', 'model', 'reasoningEffort', 'persona', 'enabled']) {
748
- if (clean[key] === undefined) continue; // 未传:保留 existing 原值
749
- if (clean[key] === '' || clean[key] === null) { delete merged[key]; continue; } // 空:清除字段
750
- merged[key] = clean[key];
751
- }
752
- // toolFilter 特殊处理:前端改成多选下拉后总是传数组,空数组 = 清除。
753
- // 请求未传 toolFilter 时保留 existing 原值(merge 语义);传了但被
754
- // sanitize 归一为空(如 allow/deny 均空)则清除。
755
- if (hadToolFilter) {
756
- const tf = clean.toolFilter;
757
- if (tf !== undefined && ((Array.isArray(tf.allow) && tf.allow.length > 0) || (Array.isArray(tf.deny) && tf.deny.length > 0))) {
758
- merged.toolFilter = { ...(Array.isArray(tf.allow) && tf.allow.length > 0 ? { allow: tf.allow } : {}), ...(Array.isArray(tf.deny) && tf.deny.length > 0 ? { deny: tf.deny } : {}) };
759
- } else {
760
- delete merged.toolFilter;
761
- }
762
- }
763
- if (merged.enabled !== undefined) merged.enabled = merged.enabled === false ? false : true;
764
- profiles.set(merged.id, { ...merged, ...(isBuiltin ? { builtin: true } : {}), persisted: true });
765
- deletedBuiltins.delete(merged.id);
766
- return persistOk(res, { id: merged.id }, persistProfiles());
767
- }
768
- if (req.method === 'POST' && sub === '/remove') {
769
- const body = await readBody(req);
770
- const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
771
- const existing = profiles.get(id);
772
- if (existing === undefined) {
773
- return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
774
- }
775
- profiles.delete(id);
776
- if (existing.builtin === true) deletedBuiltins.add(id);
777
- return persistOk(res, { id }, persistProfiles());
778
- }
779
- if (req.method === 'POST' && sub === '/reset') {
780
- const body = await readBody(req);
781
- const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
782
- const seed = BUILTIN_SEEDS.find((s) => s.id === id);
783
- if (seed === undefined) {
784
- return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" is not a builtin (nothing to reset)` });
785
- }
786
- profiles.set(id, { ...seed });
787
- deletedBuiltins.delete(id);
788
- return persistOk(res, { id }, persistProfiles());
789
- }
790
- if (req.method === 'POST' && sub === '/reset-all') {
791
- for (const seed of BUILTIN_SEEDS) {
792
- profiles.set(seed.id, { ...seed });
793
- deletedBuiltins.delete(seed.id);
794
- }
795
- return persistOk(res, { count: BUILTIN_SEEDS.length }, persistProfiles());
796
- }
797
- if (req.method === 'POST' && sub === '/set-profile-enabled') {
798
- const body = await readBody(req);
799
- const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
800
- const existing = profiles.get(id);
801
- if (existing === undefined) {
802
- return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
803
- }
804
- existing.enabled = body && body.enabled === false ? false : true;
805
- // Persist unconditionally (not just for builtins): a runtime-registered
806
- // profile's enable/disable must also survive a restart.
807
- existing.persisted = true;
808
- return persistOk(res, { id, enabled: existing.enabled }, persistProfiles());
809
- }
810
- json(res, 404, { ok: false, error: `未知路由 ${sub}` });
811
- } catch (error) {
812
- // 通用 500 不回显内部错误信息(防泄漏),详情只进宿主日志。
813
- ctx.logger.error('[dsh-subagent-profile] settings route error:', error instanceof Error ? (error.stack ?? error.message) : String(error));
814
- json(res, 500, { ok: false, error: '内部错误,详情见宿主日志' });
815
- }
816
- };
817
- scope.effect(() => {
818
- const disposeRoutes = scope.webServer.register({ kind: 'prefix', path: '/subagent-profiles', handler });
819
- return () => disposeRoutes();
820
- }, 'dsh-subagent-profile: settings routes');
821
- });
822
-
823
- // C: subagent-profiles service over the same closure Map — lets the outside
824
- // world enumerate and extend the registry without touching internals.
37
+ // subagent-profiles service over the store's per-apply profiles Map —
38
+ // lets the outside world enumerate and extend the registry without touching
39
+ // internals.
40
+ function provideProfileService(ctx, store) {
825
41
  ctx.provide('subagent-profiles', {
826
42
  register(profile) {
827
43
  if (!profile || typeof profile.id !== 'string' || profile.id.length === 0) {
828
44
  throw new Error('subagent-profiles: profile id must be a non-empty string');
829
45
  }
830
- if (profiles.has(profile.id)) {
46
+ if (store.profiles.has(profile.id)) {
831
47
  throw new Error(`subagent-profiles: profile "${profile.id}" is already registered`);
832
48
  }
833
49
  const registered = { ...profile };
834
- profiles.set(profile.id, registered);
50
+ store.profiles.set(profile.id, registered);
835
51
  return () => {
836
- if (profiles.get(profile.id) === registered) profiles.delete(profile.id);
52
+ if (store.profiles.get(profile.id) === registered) store.profiles.delete(profile.id);
837
53
  };
838
54
  },
839
55
  get(id) {
840
- return profiles.get(id);
56
+ return store.profiles.get(id);
841
57
  },
842
58
  list() {
843
- return [...profiles.values()];
59
+ return [...store.profiles.values()];
844
60
  },
845
61
  resolve(id) {
846
- return resolveProfile(id);
62
+ return store.resolveProfile(id);
847
63
  }
848
64
  });
65
+ }
849
66
 
850
- // C: directory section rendering the available profiles (systemPrompt's
851
- // section `text` accepts a function, as the shipped tool-subagent proves).
852
- const pluginSystemPrompt = ctx.get('systemPrompt');
853
- if (pluginSystemPrompt !== undefined) {
854
- // §8.1 系统提示门控:两段 profile-mode section **只在当前席 Agent 是编排者
855
- // 预设(orchestrator)**时注入,从而消除从 standard/code/minimal 等不派发
856
- // 会话上的每请求固定泄漏。
857
- //
858
- // —— 门控判据(以源码为准确认,见 test/README.md)——
859
- // 主判据(preset 特征):`composedPreset(agentCtx) === 'orchestrator'`。这是
860
- // 判别「是否编排者会话」的可靠信号:dispatch 工具由本 host 行注册、经
861
- // dsh-tools `schemas(scope)` 的 global 继承起点对**每个** agent 恒可见,因此
862
- // 「schemas(agent) 含 dispatch」在宿主架构下恒真,**不能**作为主判据(规格
863
- // 评审意见:schemas 判据降为否决)。
864
- // 否决(防御,防假阳性):若 `schemas(agent)` **明确不含** dispatch → 必空。
865
- // 生产环境恒含 dispatch(host-global),此否决在正常路径不触发;它只防御任何
866
- // 让 dispatch 从 agent 视野消失的宿主行为。
867
- // 回退:拿不到 agentPresets(composedPreset 不可用)时无法判别当前 Agent 的
868
- // 组成,保守不注入(无泄漏风险)。
869
- //
870
- // section.text(context) 由宿主以 assembleContextFor(agent, signal) =
871
- // { agent, scope: agent, signal } 调用(@deepseek-ai/dsh-agent),因此 text()
872
- // 能拿到 context.agent;Agent 实例带 .ctx(dsh-agent-presets 的
873
- // composedPreset(agentCtx) 正以 agentCtx 作为期望入参),故主判据优先取
874
- // context.agent.ctx。
875
- const sectionGatePasses = (context) => {
876
- if (!enabled) return false;
877
- // 否决(防御性):schemas(agent) 明确不含 dispatch → 必空。
878
- if (context !== undefined && context.agent !== undefined) {
879
- const schemas = ctx.tools.schemas(context.agent);
880
- if (Array.isArray(schemas) && !schemas.some((s) => s && s.name === 'dispatch')) {
881
- return false;
882
- }
883
- }
884
- // 主判据:preset 特征 —— composedPreset(agentCtx) === 'orchestrator'。
885
- const agentPresets = ctx.get('agentPresets');
886
- if (agentPresets !== undefined && typeof agentPresets.composedPreset === 'function') {
887
- const agentCtx = context !== undefined && context.agent !== undefined && context.agent.ctx !== undefined
888
- ? context.agent.ctx
889
- : ctx;
890
- try { return agentPresets.composedPreset(agentCtx) === 'orchestrator'; }
891
- catch { return false; }
892
- }
893
- // 无 agentPresets → 无法判别 → 保守不注入。
67
+ // 系统提示门控:两段 profile-mode section 只在当前会话 Agent 是编排者预设
68
+ // (orchestrator)时注入,从而消除从 standard/code/minimal 等不派发会话上的
69
+ // 每请求固定泄漏。判据(以源码为准确认,见 test/README.md):主判据
70
+ // composedPreset(agentCtx)==='orchestrator';否决 schemas(agent) 明确不含
71
+ // dispatch 必空;回退 agentPresets 保守不注入。
72
+ function sectionGatePasses(ctx, getEnabled, context) {
73
+ if (!getEnabled()) return false;
74
+ // 否决(防御性):schemas(agent) 明确不含 dispatch → 必空。
75
+ if (context !== undefined && context.agent !== undefined) {
76
+ const schemas = ctx.tools.schemas(context.agent);
77
+ if (Array.isArray(schemas) && !schemas.some((s) => s && s.name === 'dispatch')) {
894
78
  return false;
895
- };
896
- pluginSystemPrompt.section({
897
- name: 'dispatch:profiles',
898
- order: 116.5,
899
- text: (context) => {
900
- if (!sectionGatePasses(context)) return '';
901
- const rows = [...profiles.values()]
902
- .filter((p) => p.enabled !== false)
903
- .map((p) => {
904
- // 引号引用:description 套引号;为空时显示占位符(不套引号)。压平
905
- // 在存储层完成(sanitizeProfile),此处仅负责显示层包裹。
906
- const desc = typeof p.description === 'string' && p.description.length > 0 ? `"${p.description}"` : '(无描述)';
907
- return `- ${p.id}: ${desc}${p.preset !== undefined ? ` (preset: ${p.preset})` : ''}`;
908
- });
909
- if (rows.length === 0) return '';
910
- // §8.3 一行行为规则(进门控 profiles section,非常开 persona):用相对
911
- // 锚点降低「几分钟内可自查完的小任务」被派发的概率(SPEC §8.1 残留分歧
912
- // 已定:行为规则注入点取门控 profiles section)。
913
- const note = '- 别把 1-2 步即可自查/可搜完的小事委派出去 —— 几分钟内能自查完的直接做。';
914
- return `Available dispatch profiles (dispatch.profile):\n${rows.join('\n')}\n${note}`;
915
- }
916
- });
917
- // Announce the self-installed orchestrator preset so the current agent
918
- // knows the mode exists and can point the user to it. §8.1: gated the same
919
- // way — only a dispatch-capable agent sees it.
920
- pluginSystemPrompt.section({
921
- name: 'orchestrator:mode',
922
- order: 117,
923
- text: (context) => {
924
- if (!sectionGatePasses(context)) return '';
925
- return '本机已安装 dsh-subagent-profile 插件的「编排者模式」agent preset:新建会话的预设选择器中可选「编排者模式」。该模式把 Agent 定位为主协调者——拆解任务后按场景用 dispatch(内置 swap-standard=标准编码、researcher=调研检索,可在「子 Agent 方案」设置页自定义)与 subagent/subagent_fork/workflow 委派给子 Agent,再整合结果。preset 文件由插件维护于 ~/.dsh/.agent-presets,安装/升级时自动同步;用户提到「编排者模式 / orchestrator / 主协调模式」时即指本预设,请据此协作。';
926
- }
927
- });
79
+ }
80
+ }
81
+ // 主判据:preset 特征 —— composedPreset(agentCtx) === 'orchestrator'
82
+ const agentPresets = ctx.get('agentPresets');
83
+ if (agentPresets !== undefined && typeof agentPresets.composedPreset === 'function') {
84
+ const agentCtx = context !== undefined && context.agent !== undefined && context.agent.ctx !== undefined
85
+ ? context.agent.ctx
86
+ : ctx;
87
+ try { return agentPresets.composedPreset(agentCtx) === 'orchestrator'; }
88
+ catch { return false; }
928
89
  }
90
+ // 无 agentPresets → 无法判别 → 保守不注入。
91
+ return false;
92
+ }
929
93
 
930
- // 3. `profile` subagent provider.
931
- const disposeProvider = ctx.subagents.registerProvider({
932
- name: 'profile',
933
- capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
934
- inheritsParentContext: false,
935
- async start(request) {
936
- if (!enabled) {
937
- throw new Error('dispatch: the subagent-profile plugin is disabled (re-enable it in 设置 → 子 Agent 方案)');
938
- }
939
- const profile = request.profile;
940
- if (profile === undefined) {
941
- throw new Error('dispatch: request.profile is missing (the dispatch tool must resolve a profile before starting)');
942
- }
943
- const parent = request.parent;
944
- // F9: capture the delegation policy synchronously, before the first
945
- // await — a later parent switch belongs to the parent's future, not to
946
- // this child (shipped captureDelegatedPolicyOverrides). Passed to setup
947
- // through the closure.
948
- const delegated = captureDelegatedPolicyOverrides(parent);
949
- // F6: authoritative preset whitelist check against the runtime roster.
950
- const whitelist = new Set(await resolveWhitelist(parent.ctx));
951
- if (typeof profile.preset === 'string' && profile.preset !== 'inherit' && !whitelist.has(profile.preset)) {
952
- throw new Error(`dispatch: preset "${profile.preset}" is not in the target-preset whitelist`);
953
- }
954
- // F5: authoritative cost guard (runtime-derived; hard caps always applied,
955
- // llm capability gated by allowFailOpen — SPEC §7.3).
956
- await assertCostGuard(parent, profile, allowFailOpen, ctx.logger);
957
- // Delegation depth: shipped helpers — assert the cap value, then resolve
958
- // the child depth (parent floor + 1) and enforce the cap.
959
- assertSubagentMaxDepth(profile.maxDepth);
960
- const childDepth = resolveChildDepth(parent, profile.maxDepth);
961
- const childId = randomUUID();
962
- const parentAgentPresets = parent.ctx.get('agentPresets');
963
- const parentComposed = parentAgentPresets !== undefined ? parentAgentPresets.composedPreset(parent.ctx) : undefined;
964
- const swapPreset = typeof profile.preset === 'string' && profile.preset !== 'inherit' && profile.preset !== parentComposed;
965
- // F8: agentPreset is recorded only when a preset roster exists
966
- // (non-rosterless), otherwise omitted entirely. This meta is CUSTOM —
967
- // not the shipped childSessionMeta — because a swap records
968
- // profile.preset instead of the parent's composedPreset.
969
- const meta = {
970
- ...(parent.session.header.cwd !== undefined ? { cwd: parent.session.header.cwd } : {}),
971
- ...(parentAgentPresets !== undefined
972
- ? swapPreset
973
- ? { agentPreset: profile.preset }
974
- : parentComposed !== undefined
975
- ? { agentPreset: parentComposed }
976
- : {}
977
- : {}),
978
- parentSession: parent.session.header.id,
979
- origin: 'subagent',
980
- delegationDepth: childDepth
981
- };
982
- // agentOptions: shipped resolveChildAgentOptions — parent route inherited
983
- // unless the profile overrides provider/model/maxTokens, stamped with the
984
- // child's own delegation depth.
985
- const agentOptions = resolveChildAgentOptions(parent, {
986
- ...(profile.provider !== undefined ? { provider: profile.provider } : {}),
987
- ...(profile.model !== undefined ? { model: profile.model } : {}),
988
- ...(profile.maxTokens !== undefined ? { maxTokens: profile.maxTokens } : {})
989
- }, childDepth);
990
- if (request.signal !== undefined && request.signal.aborted) {
991
- throw new Error('dispatch: subagent request was aborted before child publication');
992
- }
993
- const handle = await parent.ctx.agents.create({
994
- sessionId: childId,
995
- meta,
996
- agentOptions,
997
- signal: request.signal,
998
- setup: async (childCtx) => {
999
- // ① Preset composition: explicit swap mounts the target preset;
1000
- // otherwise compose from the parent. E: rosterless + explicit
1001
- // swap fails loud instead of silently degrading. This is CUSTOM —
1002
- // not the shipped applyChildComposition, which only composes from
1003
- // the parent.
1004
- const childPresets = childCtx.get('agentPresets');
1005
- if (swapPreset) {
1006
- if (childPresets === undefined) {
1007
- throw new Error('dispatch: cannot swap preset in a rosterless deployment');
1008
- }
1009
- await childPresets.mount(childCtx, profile.preset);
1010
- } else if (childPresets !== undefined) {
1011
- childPresets.composeFrom(childCtx, parent.ctx);
1012
- }
1013
- // ② Tool intersection (safety gate 1): parent set ∩ child set, minus
1014
- // run_code, minus deny, then narrowed by allow when present.
1015
- const parentNames = new Set(parent.ctx.tools.schemas(parent).map((schema) => schema.name));
1016
- const childNames = childCtx.tools.schemas(childCtx.agent).map((schema) => schema.name);
1017
- let effective = childNames.filter((name) =>
1018
- parentNames.has(name) &&
1019
- name !== 'run_code' &&
1020
- !(profile.toolFilter !== undefined && profile.toolFilter.deny !== undefined && profile.toolFilter.deny.includes(name))
1021
- );
1022
- if (profile.toolFilter !== undefined && Array.isArray(profile.toolFilter.allow)) {
1023
- effective = effective.filter((name) => profile.toolFilter.allow.includes(name));
1024
- }
1025
- // F4: shipped restrict does NOT throw on allow:[] — fail loud here so
1026
- // the empty-intersection case is explicit (throw => setupAndPublish
1027
- // rolls the creation back).
1028
- if (effective.length === 0) {
1029
- throw new Error('dispatch: child tool intersection is empty (zero tools)');
1030
- }
1031
- // restrict throws on unknown/scope-local/reserved allow sets: wrap
1032
- // in a clean error and rethrow to trigger creation rollback.
1033
- try {
1034
- childCtx.tools.restrict({ allow: effective });
1035
- } catch (error) {
1036
- throw new Error(`dispatch: child tool restriction failed: ${error instanceof Error ? error.message : String(error)}`);
1037
- }
1038
- // ③ Delegation scope declaration (when systemPrompt is available).
1039
- const systemPrompt = childCtx.get('systemPrompt');
1040
- if (systemPrompt !== undefined) {
1041
- systemPrompt.context({ name: 'subagent:delegation', order: 120, text: DELEGATION_CONTEXT });
1042
- }
1043
- // ④ Persona shadow (overrides deployment:persona at order 0). The
1044
- // guidance marker is prefixed when the persona is non-empty (双防线):
1045
- // the injected text is `${GUIDANCE_PREFIX}${persona}`, exactly the
1046
- // length the sanitizeProfile cap validates (wrappedLength).
1047
- if (profile.persona !== undefined && systemPrompt !== undefined) {
1048
- systemPrompt.section({
1049
- name: 'deployment:persona',
1050
- order: 0,
1051
- text: profile.persona.length > 0 ? `${GUIDANCE_PREFIX}${profile.persona}` : profile.persona,
1052
- });
1053
- }
1054
- // ⑤ Reasoning-effort injection into every child request.
1055
- if (profile.reasoningEffort !== undefined) {
1056
- childCtx.on('agent/request', async (_payload, next) => {
1057
- const resolved = await next();
1058
- return { ...resolved, reasoningEffort: profile.reasoningEffort };
1059
- });
1060
- }
1061
- // ⑥ Descriptor append inside the child's first turn.
1062
- let appended = false;
1063
- childCtx.on('agent/pre-step', async ({ agent }, next) => {
1064
- const decision = await next();
1065
- if (!appended && decision.kind === 'enter') {
1066
- appended = true;
1067
- agent.session.append('subagent/descriptor', request.descriptor);
1068
- }
1069
- return decision;
1070
- });
1071
- // ⑦ Delegation policy appends (shipped helper: sandbox/mode when the
1072
- // parent has an explicit override, approval/policy pinned 'never').
1073
- appendDelegatedPolicyOverrides(childCtx.agent.session, delegated);
1074
- }
1075
- });
1076
- // F3: post-publication cancellation wiring (drivePublishedRun): the
1077
- // caller signal cancels the child and the result closure skips the
1078
- // followup when already cancelled.
1079
- const child = handle.agent;
1080
- const boundary = child.session.events.length;
1081
- const flags = { cancelled: false };
1082
- const onAbort = () => {
1083
- flags.cancelled = true;
1084
- child.cancel({ kind: 'parent' });
1085
- };
1086
- const signal = request.signal;
1087
- if (signal !== undefined) {
1088
- signal.addEventListener('abort', onAbort, { once: true });
1089
- if (signal.aborted) onAbort();
1090
- }
1091
- const result = (async () => {
1092
- try {
1093
- if (!flags.cancelled) {
1094
- child.followup(createUserMessage({ content: request.prompt, source: { kind: 'user' } }));
1095
- await child.whenIdle();
1096
- }
1097
- const settled = readResult(child, boundary, flags.cancelled);
1098
- // Decision-level log at result settlement (readResult, before return).
1099
- ctx.logger.info('[dsh-subagent-profile] child:', JSON.stringify({ childId, preset: profile.preset ?? 'inherit', swapPreset, stopReason: settled.stopReason }));
1100
- return settled;
1101
- } finally {
1102
- if (signal !== undefined) signal.removeEventListener('abort', onAbort);
1103
- }
1104
- })();
1105
- return {
1106
- id: childId,
1107
- localAgent: child,
1108
- result,
1109
- async dispose() {
1110
- if (signal !== undefined) signal.removeEventListener('abort', onAbort);
1111
- flags.cancelled = true;
1112
- const settled = await Promise.allSettled([handle.dispose(), result]);
1113
- if (settled[0].status === 'rejected') throw settled[0].reason;
1114
- }
1115
- };
1116
- },
1117
- async prepareContinuable() {
1118
- return {};
1119
- }
94
+ // dispatch:profiles section 文本。引号引用:description 套引号;为空时显示
95
+ // 占位符(不套引号)。压平在存储层完成(sanitizeProfile),此处仅负责显示层包裹。
96
+ // 门控 profiles section 内附一行行为规则提示(非开放的 persona 注入)。
97
+ function profileSectionText(store, gate, context) {
98
+ if (!gate(context)) return '';
99
+ const rows = [...store.profiles.values()]
100
+ .filter((p) => p.enabled !== false)
101
+ .map((p) => {
102
+ const desc = typeof p.description === 'string' && p.description.length > 0 ? `"${p.description}"` : '(无描述)';
103
+ return `- ${p.id}: ${desc}${p.preset !== undefined ? ` (preset: ${p.preset})` : ''}`;
104
+ });
105
+ if (rows.length === 0) return '';
106
+ const note = '- 别把 1-2 步即可自查/可搜完的小事委派出去 —— 几分钟内能自查完的直接做。';
107
+ return `Available dispatch profiles (dispatch.profile):\n${rows.join('\n')}\n${note}`;
108
+ }
109
+
110
+ // orchestrator:mode section 文本(逐字保留;与 profiles 同门控)。
111
+ const ORCHESTRATOR_MODE_TEXT = '本机已安装 dsh-subagent-profile 插件的「编排者模式」agent preset:新建会话的预设选择器中可选「编排者模式」。该模式把 Agent 定位为主协调者——拆解任务后按场景用 dispatch(内置 swap-standard=标准编码、researcher=调研检索,可在「子 Agent 方案」设置页自定义)与 subagent/subagent_fork/workflow 委派给子 Agent,再整合结果。preset 文件由插件维护于 ~/.dsh/.agent-presets,安装/升级时自动同步;用户提到「编排者模式 / orchestrator / 主协调模式」时即指本预设,请据此协作。';
112
+
113
+ // Directory section rendering the available profiles (systemPrompt's
114
+ // section `text` accepts a function, as the shipped tool-subagent proves).
115
+ // gate: `enabled` must be read live (getter), so /set-enabled toggles
116
+ // apply immediately without a restart.
117
+ function registerSystemPromptSections(ctx, store, getEnabled) {
118
+ const pluginSystemPrompt = ctx.get('systemPrompt');
119
+ if (pluginSystemPrompt === undefined) return;
120
+ const gate = (context) => sectionGatePasses(ctx, getEnabled, context);
121
+ pluginSystemPrompt.section({
122
+ name: 'dispatch:profiles',
123
+ order: 116.5,
124
+ text: (context) => profileSectionText(store, gate, context),
1120
125
  });
1121
- if (typeof disposeProvider === 'function') ctx.effect(() => disposeProvider);
126
+ // Announce the self-installed orchestrator preset so the current agent
127
+ // knows the mode exists and can point the user to it. Gated the same
128
+ // way — only a dispatch-capable agent sees it.
129
+ pluginSystemPrompt.section({
130
+ name: 'orchestrator:mode',
131
+ order: 117,
132
+ text: (context) => (gate(context) ? ORCHESTRATOR_MODE_TEXT : ''),
133
+ });
134
+ }
1122
135
 
1123
- // 4. `dispatch` tool bundle registration: the dynamic-plugin harness pair
1124
- // (harness.defineTool/harness.registerTool) does not exist in a bundle, so
1125
- // this uses the shipped ctx.tools.register + imported defineTool. The tool
1126
- // is registered dynamically so the settings switch can unregister it at
1127
- // runtime (disappearing from the model's tool list) without a restart.
1128
- const dispatchTool = defineTool({
1129
- name: 'dispatch',
1130
- 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 提示)。',
1131
- parameters: {
1132
- 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.' },
1133
- preset: { type: 'string', description: 'Explicit target preset override; must be a system-trust preset of this runtime.' },
1134
- model: { type: 'string', description: 'Explicit model override for the child.' },
1135
- provider: { type: 'string', description: 'Explicit provider override for the child.' },
1136
- reasoningEffort: { type: 'string', description: 'Explicit reasoning-effort override injected into every child request.' },
1137
- persona: { type: 'string', description: 'Persona text shadowing the child deployment:persona section.' },
1138
- toolFilter: {
1139
- type: 'object',
1140
- // F1: DSL object parameters reject unknown keys by default, so the
1141
- // toolFilter object must close its schema or defineTool throws at
1142
- // apply time and the plugin fails to load.
1143
- additionalProperties: false,
1144
- description: 'Extra tool whitelist intersection for the child (intersected with the parent tool set).',
1145
- properties: {
1146
- allow: { type: 'array', items: { type: 'string' }, description: 'When present, only these tool names are kept.' },
1147
- deny: { type: 'array', items: { type: 'string' }, description: 'These tool names are always removed.' }
1148
- }
1149
- },
1150
- maxTokens: { type: 'number', description: 'Explicit max-tokens budget for the child.' },
1151
- maxDepth: { type: 'number', description: 'Absolute delegation-depth cap for this child.' },
1152
- run_in_background: { type: 'boolean', description: '异步 one-shot:走 jobs.start 包 start(),返回 jobId;仍单轮即弃,非 continuable' },
1153
- 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.' },
1154
- // §8.2 信封模式 = opt-in(仅「中段即交付物」的任务用)。首切片**仅预留**:
1155
- // 工具 schema 暴露此参数作字段契约,execute 当前不消费它(结构化信封回收
1156
- // 在 V2.0-中期启用)。见 execute 内注释。
1157
- envelope: { type: 'boolean', description: '预留:结构化信封回收(V2.0 中期启用,当前不生效)' },
1158
- prompt: { type: 'string', required: true, description: 'The complete, self-contained task for the child (it does not see this conversation).' }
1159
- },
1160
- output: {
1161
- schema: {
1162
- // D: observability metadata on every result. OneOf covers the
1163
- // background variant (kind/jobId) and the foreground variant (output),
1164
- // both closed and both carrying the effective delegation values.
1165
- // R1: `ignored` was added to ALL three branches (with the shared `preset`
1166
- // / `provider` / `model` / `reasoningEffort` / `profile`), keeping the
1167
- // closed oneOf consistent — assertResultSchemaConsistency(dispatchTool
1168
- // .output.schema) in apply() fires if any 分支 忘补该字段.
1169
- oneOf: [
1170
- {
1171
- type: 'object',
1172
- additionalProperties: false,
1173
- properties: {
1174
- kind: { type: 'string', required: true, const: 'background' },
1175
- jobId: { type: 'string', required: true },
1176
- profile: { type: 'string' },
1177
- preset: { type: 'string' },
1178
- provider: { type: 'string' },
1179
- model: { type: 'string' },
1180
- reasoningEffort: { type: 'string' },
1181
- ignored: { type: 'array', items: { type: 'string' } }
1182
- }
1183
- },
1184
- {
1185
- type: 'object',
1186
- additionalProperties: false,
1187
- properties: {
1188
- kind: { type: 'string', required: true, const: 'continuable' },
1189
- subagentId: { type: 'string', required: true },
1190
- profile: { type: 'string' },
1191
- preset: { type: 'string' },
1192
- provider: { type: 'string' },
1193
- model: { type: 'string' },
1194
- reasoningEffort: { type: 'string' },
1195
- ignored: { type: 'array', items: { type: 'string' } }
1196
- }
1197
- },
1198
- {
1199
- type: 'object',
1200
- additionalProperties: false,
1201
- properties: {
1202
- output: { type: 'string', required: true },
1203
- profile: { type: 'string' },
1204
- preset: { type: 'string' },
1205
- provider: { type: 'string' },
1206
- model: { type: 'string' },
1207
- reasoningEffort: { type: 'string' },
1208
- ignored: { type: 'array', items: { type: 'string' } }
1209
- }
1210
- }
1211
- ]
1212
- },
1213
- render: (_args, value) => {
1214
- // §8.4: continuable 丢弃 preset 换用与 reasoningEffort —— 渲染行把
1215
- // `ignored` 列表回显出来(`reasoningEffort=<值>(ignored)`,再加 ignored 项
1216
- // 明细),让模型「看见」被丢弃项;background/foreground 无忽略项时该后缀为空。
1217
- const ignored = value.ignored !== undefined && value.ignored.length > 0
1218
- ? `(ignored: ${value.ignored.join(', ')})`
1219
- : '';
1220
- const text = value.kind === 'background'
1221
- ? `[dispatch] background job ${value.jobId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}`
1222
- : value.kind === 'continuable'
1223
- ? `[dispatch] started subagent ${value.subagentId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}`
1224
- : `[dispatch] profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}${ignored}\n\n${value.output}`;
1225
- return [{ type: 'text', text }];
1226
- }
1227
- },
1228
- isConcurrencySafe: () => true,
1229
- async execute(args, exec) {
1230
- const parent = exec.agent;
1231
- if (!parent) throw new Error('dispatch requires calling agent');
1232
- // Resolve the base profile (side channel), then overlay explicit args.
1233
- const base = args.profile !== undefined ? resolveProfile(args.profile) : {};
1234
- const merged = { ...base };
1235
- for (const key of ['preset', 'model', 'provider', 'reasoningEffort', 'persona', 'toolFilter', 'maxTokens', 'maxDepth']) {
1236
- if (args[key] !== undefined) merged[key] = args[key];
1237
- }
1238
- // Pre-check: explicit concrete preset must be in the runtime-derived
1239
- // whitelist (F6); a preset equal to the parent's composed preset is
1240
- // rewritten to 'inherit' (no swap).
1241
- if (typeof merged.preset === 'string' && merged.preset !== 'inherit') {
1242
- const whitelist = new Set(await resolveWhitelist(parent.ctx));
1243
- if (!whitelist.has(merged.preset)) {
1244
- throw new Error(`dispatch: preset "${merged.preset}" is not in the target-preset whitelist`);
1245
- }
1246
- const parentPresets = parent.ctx.get('agentPresets');
1247
- const parentComposed = parentPresets !== undefined ? parentPresets.composedPreset(parent.ctx) : undefined;
1248
- if (merged.preset === parentComposed) merged.preset = 'inherit';
1249
- }
1250
- // F5: cost guard (runtime-derived; hard caps always applied, llm capability
1251
- // gated by allowFailOpen — SPEC §7.3).
1252
- await assertCostGuard(parent, merged, allowFailOpen, ctx.logger);
1253
- // D: effective delegation values for observability.
1254
- const meta = {
1255
- profile: args.profile ?? '(inline)',
1256
- preset: merged.preset ?? 'inherit',
1257
- provider: merged.provider ?? parent.options.provider ?? '(parent)',
1258
- model: merged.model ?? parent.options.model ?? '(parent)',
1259
- reasoningEffort: merged.reasoningEffort ?? '(default)'
1260
- };
1261
- const request = {
1262
- label: String(args.prompt ?? '').slice(0, 60),
1263
- prompt: [{ type: 'text', text: args.prompt }],
1264
- parent,
1265
- signal: exec.signal,
1266
- profile: merged,
1267
- ...(merged.persona !== undefined ? { persona: merged.persona } : {}),
1268
- ...(merged.toolFilter !== undefined ? { toolFilter: merged.toolFilter } : {}),
1269
- ...(merged.maxDepth !== undefined ? { maxDepth: merged.maxDepth } : {})
1270
- };
1271
- // §8.2 结果回收默认剪枝:在 textFrom(result.output) 之前复用宿主
1272
- // toolResultPruner.pruneContent 预剪。`pruneResultOutput` 每次现取
1273
- // ctx.get('toolResultPruner') 以反映服务就绪状态;pruner 缺失时
1274
- // pruneBlocks 回退为不剪(剪枝是增强、非硬依赖)。envelope 参数虽已在
1275
- // 工具 schema 暴露(预留,V2.0-中期启用结构化信封回收),但 execute
1276
- // **不消费**它——本首切片只实现「剪枝默认」,不做信封注入。
1277
- const pruneResultOutput = (blocks) => pruneBlocks(blocks, ctx.get('toolResultPruner'));
1278
- // Decision-level log: resolved effective delegation inputs, after the
1279
- // cost guard and after request assembly, before dispatch.
1280
- ctx.logger.info('[dsh-subagent-profile] dispatch:', JSON.stringify({
1281
- profile: args.profile ?? '(inline)',
1282
- preset: merged.preset ?? 'inherit',
1283
- provider: merged.provider ?? parent.options.provider ?? '(parent)',
1284
- model: merged.model ?? parent.options.model ?? '(parent)',
1285
- reasoningEffort: merged.reasoningEffort ?? '(default)',
1286
- maxDepth: merged.maxDepth ?? null,
1287
- background: args.run_in_background === true,
1288
- continuable: args.continuable === true
1289
- }));
1290
- // Continuable (durable) path — startContinuable publishes a persistent
1291
- // child and returns its durable id; the official send_message tool drives
1292
- // later turns. Treated first so a caller asking for both background and
1293
- // continuable gets the continuable child.
1294
- if (args.continuable === true) {
1295
- // 安全 P0-b(SPEC §7.1 第 2 条):provider `start` 的 !enabled 检查只拦
1296
- // `start`,不拦 `startContinuable` —— 这里显式补上。当前 syncTool 会在
1297
- // 禁用时注销 dispatch 工具(间接门),此处是防御性兜底:禁用后
1298
- // dispatch(continuable:true) 必须 fail-loud,不得静默派生子树。
1299
- if (!enabled) {
1300
- throw new Error('dispatch: 插件已禁用(设置 → 子 Agent 方案 重新启用)');
1301
- }
1302
- if (args.run_in_background === true) {
1303
- ctx.logger.warn('[dsh-subagent-profile] dispatch: both continuable and run_in_background are true; continuable takes precedence');
1304
- }
1305
- // 已知降级:continuable 标准路径不支持 preset swap 和 reasoningEffort(subagent 包的 SubagentStartRequest 无 preset 字段、AgentOptions 无 reasoningEffort 字段)
1306
- if (merged.preset !== undefined && merged.preset !== 'inherit') {
1307
- ctx.logger.warn(`[dsh-subagent-profile] continuable mode cannot swap preset; ignoring "${merged.preset}" (child inherits the parent preset)`);
1308
- }
1309
- if (merged.reasoningEffort !== undefined) {
1310
- ctx.logger.warn(`[dsh-subagent-profile] continuable mode cannot set reasoningEffort; ignoring "${merged.reasoningEffort}"`);
1311
- }
1312
- // 安全 P0-b(SPEC §7.1 第 1 条):预加工 toolFilter 为闭集 allow。continuable
1313
- // 走宿主 applyChildComposition→tools.restrict,prepareContinuable 返回 {},
1314
- // 插件侧无法重算父∩子交集,故在此把 allow 预加工为闭集传到 request。
1315
- //
1316
- // 假设:continuable 继承父预设(preset swap 被忽略,见上方 warn)⇒
1317
- // 子工具集 ≈ 父工具集,故 父集 − run_code − deny 可安全作为 restrict 的
1318
- // allow。失效:任何导致子工具集与父工具集不一致的宿主行为变化(非仅
1319
- // preset swap——例如未来允许 swap preset、组合不同工具集等),父集都可能
1320
- // 含子集上不存在之工具 → tools.restrict 会抛「未知工具」→ 本缓解自动降级
1321
- // 为 fail-loud(保守安全)——此时必须替换为真交集(父∩子)。
1322
- const parentNames = new Set(parent.ctx.tools.schemas(parent).map((schema) => schema.name));
1323
- const effectiveAllow = computeContinuableAllow(parentNames, merged.toolFilter);
1324
- const hasAgentOptions = merged.provider !== undefined || merged.model !== undefined || merged.maxTokens !== undefined;
1325
- const continuableRequest = {
1326
- prompt: [{ type: 'text', text: args.prompt }],
1327
- parent,
1328
- ...(hasAgentOptions ? { agentOptions: {
1329
- ...(merged.provider !== undefined ? { provider: merged.provider } : {}),
1330
- ...(merged.model !== undefined ? { model: merged.model } : {}),
1331
- ...(merged.maxTokens !== undefined ? { maxTokens: merged.maxTokens } : {})
1332
- } } : {}),
1333
- ...(merged.persona !== undefined ? { persona: merged.persona } : {}),
1334
- // 恒传闭集 allow(覆盖原 merged.toolFilter 透传);空集在
1335
- // computeContinuableAllow 内 fail-loud。
1336
- toolFilter: { allow: effectiveAllow },
1337
- ...(merged.maxDepth !== undefined ? { maxDepth: merged.maxDepth } : {})
1338
- };
1339
- const { childId } = await ctx.subagents.startContinuable({
1340
- provider: 'profile',
1341
- label: String(args.prompt ?? '').slice(0, 60),
1342
- request: continuableRequest,
1343
- signal: exec.signal
1344
- });
1345
- // Continuable drops the profile's preset swap and reasoningEffort (the
1346
- // child inherits the parent preset), so the observability meta must
1347
- // report what actually took effect, not the requested-but-ignored values.
1348
- // §8.4 可见性修复:`reasoningEffort` 回显**请求值**(经 meta.reasoningEffort,
1349
- // 即 merged.reasoningEffort ?? '(default)'),`preset:'inherit'` 是真实生效值;
1350
- // `ignored` 明确列出被丢弃项,让模型「看见」被忽略的字段。
1351
- return {
1352
- kind: 'continuable',
1353
- subagentId: childId,
1354
- profile: meta.profile,
1355
- preset: 'inherit',
1356
- provider: meta.provider,
1357
- model: meta.model,
1358
- reasoningEffort: meta.reasoningEffort,
1359
- ignored: ['preset', 'reasoningEffort']
1360
- };
1361
- }
1362
- // A: background one-shot (job) path — jobs.start wraps start() with a
1363
- // native AbortController (a Node global in a bundle; the dynamic-plugin
1364
- // sandbox needed the hand-rolled shim instead); still one turn, not
1365
- // continuable.
1366
- if (args.run_in_background === true) {
1367
- const jobs = ctx.get('jobs');
1368
- if (jobs === undefined) {
1369
- throw new Error('dispatch: background jobs unavailable (load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs)');
1370
- }
1371
- const jobId = jobs.start({
1372
- kind: 'subagent',
1373
- label: String(args.prompt ?? '').slice(0, 60),
1374
- owner: parent,
1375
- run: () => {
1376
- const controller = new AbortController();
1377
- return {
1378
- cancel: (reason) => controller.abort(reason ?? 'dispatch: background subagent task killed'),
1379
- done: settleStart(ctx.subagents.start('profile', { ...request, signal: controller.signal }), controller.signal, meta, pruneResultOutput)
1380
- };
1381
- }
1382
- });
1383
- return { kind: 'background', jobId, ...meta };
1384
- }
1385
- // Foreground: collect, always release the handle (E: dispose even when
1386
- // run.result rejects), then fail loud on a non-completed stop reason.
1387
- const run = await ctx.subagents.start('profile', request);
1388
- let result;
1389
- try {
1390
- result = await run.result;
1391
- } finally {
1392
- await run.dispose().catch(() => {});
1393
- }
1394
- // F2: a non-'completed' stop reason is a failure; attach the child's
1395
- // partial output text (withPartialText style).
1396
- const failure = stopReasonError(result);
1397
- if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
1398
- return { output: textFrom(pruneResultOutput(result.output)), ...meta };
1399
- }
136
+ // HTTP loopback routes for the Client settings UI (webServer.register
137
+ // client fetch; JSON only) the routes themselves live in
138
+ // lib/core/http-routes.mjs (imported above); this wiring only injects the
139
+ // per-apply deps. webServer is optional a headless deployment keeps the
140
+ // dispatch tool and drops only the settings page. webServer's activation
141
+ // (listen) is async and may not be ready when this plugin's inject deps
142
+ // resolve, so register inside an inject sub-scope that waits for it
143
+ // (ctx.get would read undefined at apply time).
144
+ function registerSettingsRoutes(ctx, store, getEnabled, setEnabled, syncTool) {
145
+ ctx.inject(['webServer'], (scope) => {
146
+ scope.effect(createHttpRoutes({
147
+ webServer: scope.webServer,
148
+ store,
149
+ getEnabled,
150
+ setEnabled,
151
+ syncTool,
152
+ getLlm: () => ctx.get('llm'),
153
+ getAgentPresets: () => ctx.get('agentPresets'),
154
+ getTools: () => ctx.tools,
155
+ logger: ctx.logger,
156
+ }), 'dsh-subagent-profile: settings routes');
1400
157
  });
1401
- // R1(共享规则)lock: the closed oneOf result schema must carry an identical
1402
- // shared meta key set across all three branches. Fires only at apply time; a
1403
- // future meta-field add that forgets one 分支 throws here (once), so the
1404
- // model-side schema never silently rejects a分支.
1405
- assertResultSchemaConsistency(dispatchTool.output.schema);
1406
- // Register the tool only while enabled; unregister it the moment the switch
1407
- // turns off so it disappears from the model's tool list without a restart.
1408
- let disposeTool;
1409
- function syncTool() {
1410
- if (enabled && disposeTool === undefined) {
1411
- disposeTool = ctx.tools.register(dispatchTool);
1412
- } else if (!enabled && disposeTool !== undefined) {
1413
- const dispose = disposeTool;
1414
- disposeTool = undefined;
1415
- dispose();
1416
- }
1417
- }
1418
- syncTool();
1419
- ctx.effect(() => () => {
1420
- if (disposeTool !== undefined) {
1421
- const dispose = disposeTool;
1422
- disposeTool = undefined;
1423
- dispose();
1424
- }
158
+ }
159
+
160
+ export async function apply(ctx) {
161
+ // Enable/disable switch (default on, runtime-toggled by the settings
162
+ // page, persisted across restarts) + profile registry — lib/profiles-store
163
+ // .mjs: createProfileStore. loadProfiles runs once at startup via the
164
+ // explicit call below (the factory itself does not auto-load).
165
+ const store = createProfileStore({ dshHome: dshHome(), logger: ctx.logger });
166
+ let enabled = store.loadEnabled();
167
+ store.loadProfiles();
168
+ // Self-install the bundled "orchestrator" preset (idempotent, fail-soft).
169
+ syncBundledPresetsToHome(ctx);
170
+ // `dispatch` tool — lib/core/dispatch-tool.mjs: defineTool block (schema +
171
+ // execute), the result-schema consistency lock and the syncTool
172
+ // register/unregister logic.
173
+ // Created BEFORE the HTTP inject so createHttpRoutes can capture
174
+ // dispatch.syncTool (/set-enabled).
175
+ const dispatch = createDispatchTool({
176
+ register: (tool) => ctx.tools.register(tool),
177
+ store,
178
+ getEnabled: () => enabled,
179
+ getService: (name) => ctx.get(name),
180
+ logger: ctx.logger,
181
+ subagents: ctx.subagents,
1425
182
  });
183
+ // subagent-profiles service over the store's per-apply profiles Map.
184
+ provideProfileService(ctx, store);
185
+ // Gated system-prompt sections (profile directory + orchestrator mode).
186
+ registerSystemPromptSections(ctx, store, () => enabled);
187
+ // `profile` subagent provider — lib/core/profile-provider.mjs:
188
+ // createProfileProvider registers the provider and returns the disposer.
189
+ const disposeProvider = createProfileProvider({
190
+ subagents: ctx.subagents,
191
+ store,
192
+ getEnabled: () => enabled,
193
+ logger: ctx.logger,
194
+ });
195
+ if (typeof disposeProvider === 'function') ctx.effect(() => disposeProvider);
196
+ // HTTP loopback routes for the Client settings UI — lib/core/http-routes.mjs.
197
+ registerSettingsRoutes(ctx, store, () => enabled, (next) => { enabled = next; }, dispatch.syncTool);
198
+ // Teardown: unregister the dispatch tool (if still registered).
199
+ ctx.effect(() => dispatch.dispose);
1426
200
  }