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/README.md +20 -8
- package/README.zh.md +20 -8
- package/index.mjs +160 -1386
- package/lib/client.js +776 -695
- package/lib/core/catalog.mjs +83 -0
- package/lib/core/cost-guard.mjs +108 -0
- package/lib/core/delegation.mjs +61 -0
- package/lib/core/dispatch-tool.mjs +371 -0
- package/lib/core/http-routes.mjs +328 -0
- package/lib/core/intersection.mjs +27 -0
- package/lib/core/presets-sync.mjs +136 -0
- package/lib/core/profile-provider.mjs +241 -0
- package/lib/core/profiles-store.mjs +226 -0
- package/lib/{pure.mjs → core/pure.mjs} +114 -109
- package/lib/{shims.mjs → core/shims.mjs} +9 -9
- package/lib/core/whitelist.mjs +22 -0
- package/package.json +9 -5
package/index.mjs
CHANGED
|
@@ -1,527 +1,29 @@
|
|
|
1
|
-
// index.mjs — dsh-subagent-profile
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
//
|
|
1405
|
-
|
|
1406
|
-
//
|
|
1407
|
-
//
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
}
|