dsh-subagent-profile 0.1.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/LICENSE +18 -0
- package/README.md +100 -0
- package/README.zh.md +100 -0
- package/cordis.patch.yml +10 -0
- package/docs/screenshots/dispatch-card.png +0 -0
- package/docs/screenshots/settings-page1.png +0 -0
- package/docs/screenshots/settings-page2.png +0 -0
- package/index.mjs +1239 -0
- package/lib/client.js +556 -0
- package/package.json +74 -0
- package/presets/orchestrator/NOTICE +3 -0
- package/presets/orchestrator/agent.cordis.yml +273 -0
- package/presets/orchestrator/preset.yml +2 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
// index.mjs — dsh-subagent-profile host half (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, 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 { foldConsumedWork } from '@deepseek-ai/dsh-agent';
|
|
16
|
+
import {
|
|
17
|
+
appendDelegatedPolicyOverrides,
|
|
18
|
+
assertSubagentMaxDepth,
|
|
19
|
+
captureDelegatedPolicyOverrides,
|
|
20
|
+
finalAssistantOutput,
|
|
21
|
+
resolveChildAgentOptions,
|
|
22
|
+
resolveChildDepth,
|
|
23
|
+
} from '@deepseek-ai/dsh-subagent';
|
|
24
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
25
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
26
|
+
|
|
27
|
+
export const name = 'dsh-subagent-profile';
|
|
28
|
+
export const inject = ['subagents', 'tools', 'agents'];
|
|
29
|
+
|
|
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
|
+
function textFrom(blocks) {
|
|
200
|
+
return (Array.isArray(blocks) ? blocks : [])
|
|
201
|
+
.filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
|
|
202
|
+
.map((block) => block.text)
|
|
203
|
+
.join('');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Shipped toStopReason: map a turn-end reason to the seam's terminal vocabulary.
|
|
207
|
+
function toStopReason(reason) {
|
|
208
|
+
switch (reason?.kind) {
|
|
209
|
+
case 'completed': return 'completed';
|
|
210
|
+
case 'max-tokens': return 'max-tokens';
|
|
211
|
+
case 'aborted': return 'aborted';
|
|
212
|
+
case 'blocked': return 'refusal';
|
|
213
|
+
default: return 'error';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// readResult: shipped shape. The terminal turn reason comes from the imported
|
|
218
|
+
// foldConsumedWork; the selected output comes from the imported
|
|
219
|
+
// finalAssistantOutput (last non-empty assistant message, else joined
|
|
220
|
+
// text-delta chunks, else undefined -> []).
|
|
221
|
+
function readResult(child, boundary, cancelled) {
|
|
222
|
+
const own = child.session.events.slice(boundary);
|
|
223
|
+
const end = foldConsumedWork(own).end;
|
|
224
|
+
const recorded = toStopReason(end?.data.reason);
|
|
225
|
+
const stopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded;
|
|
226
|
+
return { output: finalAssistantOutput(own) ?? [], stopReason };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Shipped stopReasonError + withPartialText wording (dsh-tool-subagent L55-75).
|
|
230
|
+
function stopReasonError(result) {
|
|
231
|
+
switch (result.stopReason) {
|
|
232
|
+
case 'completed': return;
|
|
233
|
+
case 'aborted': return 'dispatch: subagent run was cancelled';
|
|
234
|
+
case 'error': return 'dispatch: subagent run failed';
|
|
235
|
+
case 'max-tokens': return 'dispatch: subagent run hit its token limit before finishing';
|
|
236
|
+
case 'refusal': return 'dispatch: subagent declined the task';
|
|
237
|
+
default: return `dispatch: subagent run ended abnormally (${String(result.stopReason)})`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function withPartialText(error, output) {
|
|
242
|
+
const text = (Array.isArray(output) ? output : [])
|
|
243
|
+
.filter((block) => block && block.type === 'text')
|
|
244
|
+
.map((block) => block.text)
|
|
245
|
+
.join('');
|
|
246
|
+
return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// F5: conservative delegation caps for maxTokens / maxDepth.
|
|
250
|
+
const MAX_TOKENS = 65536;
|
|
251
|
+
const MAX_DEPTH = 3;
|
|
252
|
+
|
|
253
|
+
// F5: runtime-derived cost guard. Reads the optional `llm` service (undefined =>
|
|
254
|
+
// guard skipped) and validates provider / model / reasoningEffort against the
|
|
255
|
+
// live provider directory, plus the maxTokens/maxDepth caps. Used by both the
|
|
256
|
+
// provider's authoritative check and the dispatch tool's pre-check.
|
|
257
|
+
async function assertCostGuard(parent, profile) {
|
|
258
|
+
const llm = parent.ctx.get('llm');
|
|
259
|
+
if (llm === undefined) return;
|
|
260
|
+
if (typeof profile.provider === 'string' && profile.provider.length > 0) {
|
|
261
|
+
const providers = await llm.listProviders();
|
|
262
|
+
if (!(providers ?? []).some((provider) => provider && provider.id === profile.provider)) {
|
|
263
|
+
throw new Error(`dispatch: provider "${profile.provider}" is not a registered provider`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const effectiveProvider = profile.provider !== undefined ? profile.provider : parent.options.provider;
|
|
267
|
+
const effectiveModel = profile.model !== undefined ? profile.model : parent.options.model;
|
|
268
|
+
if (typeof profile.model === 'string' && profile.model.length > 0) {
|
|
269
|
+
// resolveModelInfo does not reject unknown models (catalog membership is
|
|
270
|
+
// advisory), so validate against the advertised catalog instead. An EMPTY
|
|
271
|
+
// catalog (adapter without discovery) cannot be verified and is skipped; a
|
|
272
|
+
// non-empty catalog that does not advertise the model fails loud. An
|
|
273
|
+
// unverifiable lookup (listModels(undefined) when no provider is known)
|
|
274
|
+
// becomes a clean fail-loud error instead of leaking "undefined".
|
|
275
|
+
let models;
|
|
276
|
+
try {
|
|
277
|
+
models = await llm.listModels(effectiveProvider);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
throw new Error(`dispatch: cannot validate model "${profile.model}" without a provider: ${error instanceof Error ? error.message : String(error)}`);
|
|
280
|
+
}
|
|
281
|
+
const listed = models ?? [];
|
|
282
|
+
const known = listed.length > 0 && listed.some((model) => model && (model.id === profile.model || model.name === profile.model));
|
|
283
|
+
// Throw only when the catalog is non-empty AND the model is not in it; an
|
|
284
|
+
// empty catalog (adapter without discovery) is skipped, not rejected.
|
|
285
|
+
if (listed.length > 0 && !known) {
|
|
286
|
+
throw new Error(`dispatch: model "${profile.model}" is not advertised by provider "${String(effectiveProvider)}"`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (typeof profile.reasoningEffort === 'string' && profile.reasoningEffort.length > 0) {
|
|
290
|
+
try {
|
|
291
|
+
await llm.resolveCallConfig({ provider: effectiveProvider, model: effectiveModel, reasoningEffort: profile.reasoningEffort });
|
|
292
|
+
} catch (error) {
|
|
293
|
+
throw new Error(`dispatch: reasoningEffort "${profile.reasoningEffort}" is not supported by provider "${String(effectiveProvider)}" model "${String(effectiveModel)}": ${error instanceof Error ? error.message : String(error)}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (typeof profile.maxTokens === 'number' && profile.maxTokens > MAX_TOKENS) {
|
|
297
|
+
throw new Error(`dispatch: maxTokens ${profile.maxTokens} exceeds the delegation cap ${MAX_TOKENS}`);
|
|
298
|
+
}
|
|
299
|
+
if (typeof profile.maxDepth === 'number' && profile.maxDepth > MAX_DEPTH) {
|
|
300
|
+
throw new Error(`dispatch: maxDepth ${profile.maxDepth} exceeds the delegation cap ${MAX_DEPTH}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Settle one background one-shot run into a job outcome with the same
|
|
305
|
+
// observability metadata the foreground path reports. Non-completed stop reasons
|
|
306
|
+
// become failed (aborted => killed, shipped vocabulary) with partial output
|
|
307
|
+
// attached; hard failures never reject the job.
|
|
308
|
+
async function settleStart(start, signal, meta) {
|
|
309
|
+
let run;
|
|
310
|
+
try {
|
|
311
|
+
run = await start;
|
|
312
|
+
const result = await run.result;
|
|
313
|
+
const failure = stopReasonError(result);
|
|
314
|
+
if (failure !== undefined) {
|
|
315
|
+
return { status: result.stopReason === 'aborted' ? 'killed' : 'failed', detail: withPartialText(failure, result.output), ...meta };
|
|
316
|
+
}
|
|
317
|
+
return { status: 'completed', output: textFrom(result.output), ...meta };
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return signal.aborted ? { status: 'killed', ...meta } : { status: 'failed', detail: String(error), ...meta };
|
|
320
|
+
} finally {
|
|
321
|
+
// Release the child handle no matter how the result settled — run.result
|
|
322
|
+
// rejecting must not leak the subagent (same discipline as the foreground
|
|
323
|
+
// try/finally).
|
|
324
|
+
if (run !== undefined) await run.dispose().catch(() => {});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Verbatim from the shipped SUBAGENT_DELEGATION_CONTEXT.
|
|
329
|
+
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.';
|
|
330
|
+
|
|
331
|
+
export async function apply(ctx) {
|
|
332
|
+
// 0. Enable/disable switch: default on, toggled at runtime by the settings
|
|
333
|
+
// page and persisted across restarts. When off, the dispatch tool is
|
|
334
|
+
// unregistered so it disappears from the model's tool list.
|
|
335
|
+
let enabled = loadEnabled();
|
|
336
|
+
|
|
337
|
+
// 1. Profile registry (per-instance state; a bundle row is process-level, so
|
|
338
|
+
// this Map is the singleton store, exactly like the dynamic plugin's).
|
|
339
|
+
// Builtin seeds carry `builtin: true` so reset/remove can identify them and
|
|
340
|
+
// a modified builtin stays distinguishable from a pure user profile.
|
|
341
|
+
// Descriptions are semantic: one-line positioning + when to use, to help
|
|
342
|
+
// the model choose. reasoningEffort levels verified against the
|
|
343
|
+
// llm-deepseek adapter (off/high/max; see resolveModel gating on
|
|
344
|
+
// connection.defaults.thinking — this deployment leaves thinking unset,
|
|
345
|
+
// so the full set is advertised for deepseek-v4-flash).
|
|
346
|
+
const BUILTIN_SEEDS = [
|
|
347
|
+
{ id: 'swap-standard', name: '标准编码', description: '切换到 standard 预设的完整编码工具集。当父会话不是 standard、但子任务需要完整编码能力时用。', preset: 'standard', builtin: true },
|
|
348
|
+
{ 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 }
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
// Tool-name → 中文说明 map, shown beside the raw tool name in the toolFilter
|
|
352
|
+
// picker. Tools absent here fall back to their raw name.
|
|
353
|
+
const TOOL_ZH = {
|
|
354
|
+
'bash': '终端命令',
|
|
355
|
+
'pwsh': 'PowerShell 命令',
|
|
356
|
+
'read': '读取文件',
|
|
357
|
+
'write': '写入文件',
|
|
358
|
+
'edit': '编辑文件',
|
|
359
|
+
'grep': '搜索文件内容',
|
|
360
|
+
'glob': '查找文件',
|
|
361
|
+
'web_search': '网页搜索',
|
|
362
|
+
'browser_navigate': '浏览器打开网址',
|
|
363
|
+
'browser_snapshot': '浏览器页面快照',
|
|
364
|
+
'browser_click': '浏览器点击',
|
|
365
|
+
'browser_type': '浏览器输入',
|
|
366
|
+
'browser_scroll': '浏览器滚动',
|
|
367
|
+
'browser_back': '浏览器后退',
|
|
368
|
+
'browser_forward': '浏览器前进',
|
|
369
|
+
'browser_press': '浏览器按键',
|
|
370
|
+
'browser_reload': '浏览器刷新',
|
|
371
|
+
'browser_wait': '浏览器等待',
|
|
372
|
+
'browser_get_text': '读取页面文本',
|
|
373
|
+
'dispatch': '派发子 Agent',
|
|
374
|
+
'subagent': '派生子 Agent',
|
|
375
|
+
'subagent_fork': '派生子 Agent(继承上下文)',
|
|
376
|
+
'send_message': '给子 Agent 发消息',
|
|
377
|
+
'interrupt_agent': '中断子 Agent',
|
|
378
|
+
'list_agents': '列出子 Agent',
|
|
379
|
+
'todo_write': '任务清单',
|
|
380
|
+
'create_goal': '创建目标',
|
|
381
|
+
'get_goal': '查看目标',
|
|
382
|
+
'update_goal': '更新目标',
|
|
383
|
+
'workflow': '编排多 Agent 工作流',
|
|
384
|
+
'ralph': 'Ralph 迭代',
|
|
385
|
+
'ask_user_question': '询问用户',
|
|
386
|
+
'skill': '加载技能',
|
|
387
|
+
'describe_image': '描述图片',
|
|
388
|
+
'read_image': '读取图片',
|
|
389
|
+
'modlens_read_image': '读取图片(modlens)',
|
|
390
|
+
'ssh_list': '列出 SSH 主机',
|
|
391
|
+
'ssh_exec': 'SSH 执行命令',
|
|
392
|
+
'ssh_upload': 'SSH 上传',
|
|
393
|
+
'ssh_download': 'SSH 下载',
|
|
394
|
+
'ssh_tunnel': 'SSH 隧道',
|
|
395
|
+
'ssh_cluster': 'SSH 集群执行',
|
|
396
|
+
'exit_plan_mode': '退出计划模式',
|
|
397
|
+
'incident_resolved': '标记事故已解决',
|
|
398
|
+
'dsh_rollback': '回滚 DSH',
|
|
399
|
+
'dsh_snapshot': 'DSH 快照',
|
|
400
|
+
'job_list': '列出后台任务',
|
|
401
|
+
'job_output': '读取后台任务输出',
|
|
402
|
+
'job_kill': '终止后台任务',
|
|
403
|
+
'str_replace_editor': '文本编辑',
|
|
404
|
+
'cordis_inspect_list': '列出 Cordis 服务',
|
|
405
|
+
'cordis_inspect_query': '查询 Cordis 服务',
|
|
406
|
+
'cordis_inspect_self': '查看自身 Cordis 服务',
|
|
407
|
+
'cordis_define': '定义 Cordis 服务',
|
|
408
|
+
'cordis_run': '运行 Cordis 服务',
|
|
409
|
+
'cordis_stop': '停止 Cordis 服务',
|
|
410
|
+
'cordis_undefine': '取消定义 Cordis 服务',
|
|
411
|
+
'run_code': '运行代码',
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// Tool-name → 功能分类 map,覆盖 DSH 官方核心工具(固定集合)。插件工具
|
|
415
|
+
// 走前缀提取(见 categoryOf),自建预设用 preset 名。
|
|
416
|
+
const TOOL_CATEGORY = {
|
|
417
|
+
'read': '文件', 'write': '文件', 'edit': '文件', 'grep': '文件', 'glob': '文件', 'str_replace_editor': '文件',
|
|
418
|
+
'bash': '终端', 'pwsh': '终端',
|
|
419
|
+
'web_search': '网络',
|
|
420
|
+
'todo_write': '任务', 'create_goal': '任务', 'get_goal': '任务', 'update_goal': '任务',
|
|
421
|
+
'subagent': '子 Agent', 'subagent_fork': '子 Agent', 'send_message': '子 Agent', 'interrupt_agent': '子 Agent', 'list_agents': '子 Agent',
|
|
422
|
+
'workflow': '工作流', 'ralph': '工作流',
|
|
423
|
+
'ask_user_question': '交互', 'skill': '交互',
|
|
424
|
+
'read_image': '图片', 'describe_image': '图片',
|
|
425
|
+
'cordis_inspect_list': 'Cordis', 'cordis_inspect_query': 'Cordis', 'cordis_inspect_self': 'Cordis',
|
|
426
|
+
'cordis_define': 'Cordis', 'cordis_run': 'Cordis', 'cordis_stop': 'Cordis', 'cordis_undefine': 'Cordis',
|
|
427
|
+
'exit_plan_mode': '计划',
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const profiles = new Map(BUILTIN_SEEDS.map((p) => [p.id, { ...p }]));
|
|
431
|
+
|
|
432
|
+
// Builtin ids the user has deleted (soft delete via persisted tombstone). The
|
|
433
|
+
// Map keeps working entries; this Set records tombstones so they survive
|
|
434
|
+
// restarts and a later reset can clear them.
|
|
435
|
+
const deletedBuiltins = new Set();
|
|
436
|
+
|
|
437
|
+
// F6: the target-preset whitelist is derived from the runtime roster, not
|
|
438
|
+
// hard-coded: system-trust presets when agentPresets exists, else the
|
|
439
|
+
// shipped fallback names.
|
|
440
|
+
const FALLBACK_WHITELIST = ['standard', 'code', 'minimal'];
|
|
441
|
+
async function resolveWhitelist(agentCtx) {
|
|
442
|
+
const agentPresets = agentCtx.get('agentPresets');
|
|
443
|
+
if (agentPresets === undefined) return FALLBACK_WHITELIST;
|
|
444
|
+
const presets = await agentPresets.list();
|
|
445
|
+
return (presets ?? []).filter((preset) => preset && preset.trust === 'system').map((preset) => preset.id);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function resolveProfile(id) {
|
|
449
|
+
const found = profiles.get(id);
|
|
450
|
+
if (found === undefined) throw new Error(`dispatch: unknown profile "${id}"`);
|
|
451
|
+
if (found.enabled === false) throw new Error(`dispatch: profile "${id}" is disabled`);
|
|
452
|
+
return found;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 1b. User profile persistence. The settings service cannot serve this
|
|
456
|
+
// plugin (its write path hard-requires register(ns, schema) + a schemastery
|
|
457
|
+
// schema), so user profiles are persisted to ~/.dsh/subagent-profiles.json
|
|
458
|
+
// through node:fs — a bundle has node globals, unlike the dynamic-plugin
|
|
459
|
+
// sandbox that needed the optional `fs` service. Loaded once at startup; the
|
|
460
|
+
// add/remove HTTP routes rewrite the file. Persistence is an enhancement, not
|
|
461
|
+
// a hard dependency: any failure only warns and the builtin seeds work.
|
|
462
|
+
const profilesFile = join(dshHome(), 'subagent-profiles.json');
|
|
463
|
+
function loadProfiles() {
|
|
464
|
+
if (!existsSync(profilesFile)) return;
|
|
465
|
+
try {
|
|
466
|
+
const parsed = JSON.parse(readFileSync(profilesFile, 'utf8'));
|
|
467
|
+
if (!Array.isArray(parsed)) return;
|
|
468
|
+
let loaded = 0;
|
|
469
|
+
for (const entry of parsed) {
|
|
470
|
+
if (!entry || typeof entry.id !== 'string' || entry.id.length === 0) continue;
|
|
471
|
+
if (entry.deleted === true) {
|
|
472
|
+
if (entry.builtin === true) {
|
|
473
|
+
profiles.delete(entry.id);
|
|
474
|
+
deletedBuiltins.add(entry.id);
|
|
475
|
+
}
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const existing = profiles.get(entry.id);
|
|
479
|
+
if (existing !== undefined && existing.builtin === true) {
|
|
480
|
+
profiles.set(entry.id, { ...entry, builtin: true, persisted: true });
|
|
481
|
+
} else {
|
|
482
|
+
profiles.set(entry.id, { ...entry, persisted: true });
|
|
483
|
+
}
|
|
484
|
+
loaded++;
|
|
485
|
+
}
|
|
486
|
+
// Silent success: report how many persisted profiles came in (skipped
|
|
487
|
+
// when none — a missing/empty file is the normal first boot).
|
|
488
|
+
if (loaded > 0) ctx.logger.info(`[dsh-subagent-profile] loaded ${loaded} persisted profile(s)`);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
ctx.logger.warn('[dsh-subagent-profile] persisted profile load failed:', error instanceof Error ? error.message : String(error));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
loadProfiles();
|
|
494
|
+
|
|
495
|
+
// 1c. Self-install the bundled "orchestrator" agent preset into the DSH
|
|
496
|
+
// agent-presets root so the mode appears in the new-session picker without
|
|
497
|
+
// manual copying (mirrors the shipped dsh-liangshen self-install). Idempotent:
|
|
498
|
+
// byte-identical trees are skipped; a bundle change rewrites the preset — the
|
|
499
|
+
// intended upgrade path. Fail-soft: the dispatch tool and settings page keep
|
|
500
|
+
// working even if the write is denied.
|
|
501
|
+
try {
|
|
502
|
+
const presetRoot = join(dshHome(), '.agent-presets');
|
|
503
|
+
const sync = syncBundledPresets(presetRoot);
|
|
504
|
+
for (const { id, error } of sync.failed) ctx.logger.warn(`[dsh-subagent-profile] preset ${id} sync failed: ${error}`);
|
|
505
|
+
if (sync.synced.length > 0) ctx.logger.info(`[dsh-subagent-profile] presets synced into ${presetRoot}: ${sync.synced.join(', ')}`);
|
|
506
|
+
} catch (error) {
|
|
507
|
+
ctx.logger.warn('[dsh-subagent-profile] preset sync failed:', error instanceof Error ? error.message : String(error));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Persist every `persisted: true` profile plus builtin-delete tombstones — fail-soft. */
|
|
511
|
+
function persistProfiles() {
|
|
512
|
+
try {
|
|
513
|
+
const entries = [];
|
|
514
|
+
for (const profile of profiles.values()) {
|
|
515
|
+
if (profile.persisted !== true) continue;
|
|
516
|
+
const clean = {};
|
|
517
|
+
for (const [key, value] of Object.entries(profile)) {
|
|
518
|
+
if (value === undefined || key === 'persisted') continue;
|
|
519
|
+
clean[key] = value;
|
|
520
|
+
}
|
|
521
|
+
entries.push(clean);
|
|
522
|
+
}
|
|
523
|
+
for (const id of deletedBuiltins) {
|
|
524
|
+
entries.push({ id, builtin: true, deleted: true });
|
|
525
|
+
}
|
|
526
|
+
writeFileSync(profilesFile, JSON.stringify(entries, null, 2), 'utf8');
|
|
527
|
+
} catch (error) {
|
|
528
|
+
ctx.logger.warn('[dsh-subagent-profile] persisted profile write failed:', error instanceof Error ? error.message : String(error));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// 1c. HTTP loopback routes for the Client settings UI (webServer.register ↔
|
|
533
|
+
// client fetch; JSON only). webServer is optional — a headless deployment
|
|
534
|
+
// keeps the dispatch tool and drops only the settings page. webServer's
|
|
535
|
+
// activation (listen) is async and may not be ready when this plugin's
|
|
536
|
+
// inject deps resolve, so register inside an inject sub-scope that waits for
|
|
537
|
+
// it (ctx.get would read undefined at apply time).
|
|
538
|
+
ctx.inject(['webServer'], (scope) => {
|
|
539
|
+
const json = (res, code, data) => {
|
|
540
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
541
|
+
res.end(JSON.stringify(data));
|
|
542
|
+
};
|
|
543
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
544
|
+
let data = '';
|
|
545
|
+
let size = 0;
|
|
546
|
+
req.on('data', (chunk) => {
|
|
547
|
+
size += chunk.length;
|
|
548
|
+
if (size > 1 << 20) { reject(new Error('请求体过大')); req.destroy(); return; }
|
|
549
|
+
data += chunk;
|
|
550
|
+
});
|
|
551
|
+
req.on('end', () => {
|
|
552
|
+
try { resolve(data === '' ? {} : JSON.parse(data)); } catch { reject(new Error('请求体不是合法 JSON')); }
|
|
553
|
+
});
|
|
554
|
+
req.on('error', reject);
|
|
555
|
+
});
|
|
556
|
+
const listClean = () => [...profiles.values()].map((profile) => {
|
|
557
|
+
const clean = {};
|
|
558
|
+
for (const [key, value] of Object.entries(profile)) if (value !== undefined && key !== 'persisted') clean[key] = value;
|
|
559
|
+
// The internal `persisted` flag is stripped above; expose a UI-facing
|
|
560
|
+
// "modified" signal so the reset panel can label a changed builtin.
|
|
561
|
+
if (profile.builtin === true && profile.persisted === true) clean.modified = true;
|
|
562
|
+
return clean;
|
|
563
|
+
});
|
|
564
|
+
const handler = async (req, res) => {
|
|
565
|
+
const remote = req.socket?.remoteAddress;
|
|
566
|
+
if (!LOOPBACKS.has(remote)) return json(res, 403, { ok: false, error: '仅限本机访问' });
|
|
567
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
568
|
+
const sub = (url.pathname.replace(/^\/subagent-profiles/, '') || '/').replace(/\/+$/, '') || '/';
|
|
569
|
+
try {
|
|
570
|
+
if (req.method === 'GET' && (sub === '/' || sub === '/list')) {
|
|
571
|
+
return json(res, 200, { ok: true, profiles: listClean() });
|
|
572
|
+
}
|
|
573
|
+
if (req.method === 'GET' && sub === '/options') {
|
|
574
|
+
const models = [];
|
|
575
|
+
const efforts = {};
|
|
576
|
+
const presets = [];
|
|
577
|
+
// Model directory + per-model reasoning-effort levels. The `llm`
|
|
578
|
+
// service is optional (headless): a failure only empties the lists,
|
|
579
|
+
// never breaks the settings page.
|
|
580
|
+
const llm = ctx.get('llm');
|
|
581
|
+
if (llm !== undefined) {
|
|
582
|
+
try {
|
|
583
|
+
const providers = await llm.listProviders();
|
|
584
|
+
for (const provider of (providers ?? [])) {
|
|
585
|
+
const providerId = provider && provider.id;
|
|
586
|
+
if (typeof providerId !== 'string') continue;
|
|
587
|
+
let modelList = [];
|
|
588
|
+
try { modelList = await llm.listModels(providerId); } catch { /* skip this provider's catalog */ }
|
|
589
|
+
for (const model of (modelList ?? [])) {
|
|
590
|
+
if (!model || typeof model.id !== 'string') continue;
|
|
591
|
+
models.push({
|
|
592
|
+
provider: providerId,
|
|
593
|
+
providerName: provider.name ?? providerId,
|
|
594
|
+
id: model.id,
|
|
595
|
+
name: model.name ?? model.id
|
|
596
|
+
});
|
|
597
|
+
try {
|
|
598
|
+
const info = await llm.resolveModelInfo(providerId, model.id);
|
|
599
|
+
const effortsList = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : [];
|
|
600
|
+
efforts[model.id] = effortsList.map((effort) => ({
|
|
601
|
+
id: effort.id,
|
|
602
|
+
name: effort.name ?? effort.id,
|
|
603
|
+
...(effort.description !== undefined ? { description: effort.description } : {})
|
|
604
|
+
}));
|
|
605
|
+
} catch { /* exact-model lookup may reject; skip its efforts */ }
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
} catch { /* llm directory unavailable; leave options empty */ }
|
|
609
|
+
}
|
|
610
|
+
// System-trust presets (agentPresets is optional; fail-soft).
|
|
611
|
+
const agentPresets = ctx.get('agentPresets');
|
|
612
|
+
if (agentPresets !== undefined) {
|
|
613
|
+
try {
|
|
614
|
+
const list = await agentPresets.list();
|
|
615
|
+
for (const preset of (list ?? [])) {
|
|
616
|
+
if (preset && preset.trust === 'system') {
|
|
617
|
+
presets.push({ id: preset.id, name: preset.name ?? preset.id });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
} catch { /* presets roster unavailable; leave empty */ }
|
|
621
|
+
}
|
|
622
|
+
// Full tool directory = global layer (deployment plugins) + every
|
|
623
|
+
// preset's standing scope (the agent.cordis.yml tool rows). Each tool
|
|
624
|
+
// is tagged with its source: 'global' or the preset id — the grouping
|
|
625
|
+
// is fully dynamic, derived from the runtime's preset roster.
|
|
626
|
+
let tools = [];
|
|
627
|
+
try {
|
|
628
|
+
const seen = new Set();
|
|
629
|
+
const OFFICIAL_PRESETS = ['standard', 'code', 'minimal', 'cordis'];
|
|
630
|
+
const layerOf = (source) => {
|
|
631
|
+
if (source === 'global') return 'plugin';
|
|
632
|
+
if (OFFICIAL_PRESETS.includes(source)) return 'core';
|
|
633
|
+
return 'custom';
|
|
634
|
+
};
|
|
635
|
+
const groupOf = (name, source) => {
|
|
636
|
+
const layer = layerOf(source);
|
|
637
|
+
if (layer === 'core') return TOOL_CATEGORY[name] ?? '其他';
|
|
638
|
+
if (layer === 'plugin') return name.includes('_') ? name.split('_')[0] : name;
|
|
639
|
+
return source;
|
|
640
|
+
};
|
|
641
|
+
const push = (schemas, source) => {
|
|
642
|
+
for (const s of (Array.isArray(schemas) ? schemas : [])) {
|
|
643
|
+
if (!s || typeof s.name !== 'string' || s.name === 'run_code' || seen.has(s.name)) continue;
|
|
644
|
+
seen.add(s.name);
|
|
645
|
+
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) });
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
if (ctx.tools && typeof ctx.tools.schemas === 'function') {
|
|
649
|
+
push(ctx.tools.schemas(), 'global');
|
|
650
|
+
const agentPresets = ctx.get('agentPresets');
|
|
651
|
+
if (agentPresets !== undefined && typeof agentPresets.list === 'function' && typeof agentPresets.standingKeyFor === 'function') {
|
|
652
|
+
const presets = await agentPresets.list();
|
|
653
|
+
for (const preset of (presets ?? [])) {
|
|
654
|
+
if (!preset || typeof preset.id !== 'string') continue;
|
|
655
|
+
try {
|
|
656
|
+
push(ctx.tools.schemas(await agentPresets.standingKeyFor(preset.id)), preset.id);
|
|
657
|
+
} catch { /* one preset's standing scope unavailable; skip */ }
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
} catch (error) {
|
|
662
|
+
ctx.logger.warn('[dsh-subagent-profile] tools directory failed:', error instanceof Error ? error.message : String(error));
|
|
663
|
+
}
|
|
664
|
+
return json(res, 200, { ok: true, enabled, models, efforts, presets, tools });
|
|
665
|
+
}
|
|
666
|
+
if (req.method === 'POST' && sub === '/set-enabled') {
|
|
667
|
+
const body = await readBody(req);
|
|
668
|
+
const next = !!(body && body.enabled === true);
|
|
669
|
+
enabled = next;
|
|
670
|
+
persistEnabled(next);
|
|
671
|
+
syncTool();
|
|
672
|
+
return json(res, 200, { ok: true, enabled });
|
|
673
|
+
}
|
|
674
|
+
if (req.method === 'POST' && sub === '/add') {
|
|
675
|
+
const body = await readBody(req);
|
|
676
|
+
const profile = body && typeof body === 'object' ? body : {};
|
|
677
|
+
if (typeof profile.id !== 'string' || profile.id.length === 0) {
|
|
678
|
+
return json(res, 400, { ok: false, error: 'subagent-profiles: profile id must be a non-empty string' });
|
|
679
|
+
}
|
|
680
|
+
const existing = profiles.get(profile.id);
|
|
681
|
+
const seed = BUILTIN_SEEDS.find((s) => s.id === profile.id);
|
|
682
|
+
const isBuiltin = (existing !== undefined && existing.builtin === true) || seed !== undefined;
|
|
683
|
+
// Merge (not replace): start from the existing profile — or its seed
|
|
684
|
+
// when it was deleted — so fields not present in the form (e.g. a
|
|
685
|
+
// builtin's persona/preset) survive an edit or a re-add.
|
|
686
|
+
const clean = { ...(existing ?? seed ?? {}) };
|
|
687
|
+
clean.id = profile.id;
|
|
688
|
+
for (const key of ['name', 'description', 'preset', 'provider', 'model', 'reasoningEffort', 'persona', 'enabled']) {
|
|
689
|
+
if (profile[key] === undefined) continue; // 未传:保留 existing 原值
|
|
690
|
+
if (profile[key] === '' || profile[key] === null) { delete clean[key]; continue; } // 空:清除字段
|
|
691
|
+
clean[key] = profile[key];
|
|
692
|
+
}
|
|
693
|
+
// toolFilter 特殊处理:前端改成多选下拉后总是传数组,空数组 = 清除
|
|
694
|
+
if (profile.toolFilter !== undefined) {
|
|
695
|
+
const tf = profile.toolFilter;
|
|
696
|
+
const allow = Array.isArray(tf.allow) ? tf.allow.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
697
|
+
const deny = Array.isArray(tf.deny) ? tf.deny.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
698
|
+
if (allow.length > 0 || deny.length > 0) clean.toolFilter = { ...(allow.length > 0 ? { allow } : {}), ...(deny.length > 0 ? { deny } : {}) };
|
|
699
|
+
else delete clean.toolFilter;
|
|
700
|
+
}
|
|
701
|
+
if (clean.enabled !== undefined) clean.enabled = clean.enabled === false ? false : true;
|
|
702
|
+
profiles.set(profile.id, { ...clean, ...(isBuiltin ? { builtin: true } : {}), persisted: true });
|
|
703
|
+
deletedBuiltins.delete(profile.id);
|
|
704
|
+
persistProfiles();
|
|
705
|
+
return json(res, 200, { ok: true, id: profile.id });
|
|
706
|
+
}
|
|
707
|
+
if (req.method === 'POST' && sub === '/remove') {
|
|
708
|
+
const body = await readBody(req);
|
|
709
|
+
const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
|
|
710
|
+
const existing = profiles.get(id);
|
|
711
|
+
if (existing === undefined) {
|
|
712
|
+
return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
|
|
713
|
+
}
|
|
714
|
+
profiles.delete(id);
|
|
715
|
+
if (existing.builtin === true) deletedBuiltins.add(id);
|
|
716
|
+
persistProfiles();
|
|
717
|
+
return json(res, 200, { ok: true, id });
|
|
718
|
+
}
|
|
719
|
+
if (req.method === 'POST' && sub === '/reset') {
|
|
720
|
+
const body = await readBody(req);
|
|
721
|
+
const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
|
|
722
|
+
const seed = BUILTIN_SEEDS.find((s) => s.id === id);
|
|
723
|
+
if (seed === undefined) {
|
|
724
|
+
return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" is not a builtin (nothing to reset)` });
|
|
725
|
+
}
|
|
726
|
+
profiles.set(id, { ...seed });
|
|
727
|
+
deletedBuiltins.delete(id);
|
|
728
|
+
persistProfiles();
|
|
729
|
+
return json(res, 200, { ok: true, id });
|
|
730
|
+
}
|
|
731
|
+
if (req.method === 'POST' && sub === '/reset-all') {
|
|
732
|
+
for (const seed of BUILTIN_SEEDS) {
|
|
733
|
+
profiles.set(seed.id, { ...seed });
|
|
734
|
+
deletedBuiltins.delete(seed.id);
|
|
735
|
+
}
|
|
736
|
+
persistProfiles();
|
|
737
|
+
return json(res, 200, { ok: true, count: BUILTIN_SEEDS.length });
|
|
738
|
+
}
|
|
739
|
+
if (req.method === 'POST' && sub === '/set-profile-enabled') {
|
|
740
|
+
const body = await readBody(req);
|
|
741
|
+
const id = body && typeof body === 'object' && typeof body.id === 'string' ? body.id : '';
|
|
742
|
+
const existing = profiles.get(id);
|
|
743
|
+
if (existing === undefined) {
|
|
744
|
+
return json(res, 404, { ok: false, error: `subagent-profiles: profile "${id}" does not exist` });
|
|
745
|
+
}
|
|
746
|
+
existing.enabled = body && body.enabled === false ? false : true;
|
|
747
|
+
// Persist unconditionally (not just for builtins): a runtime-registered
|
|
748
|
+
// profile's enable/disable must also survive a restart.
|
|
749
|
+
existing.persisted = true;
|
|
750
|
+
persistProfiles();
|
|
751
|
+
return json(res, 200, { ok: true, id, enabled: existing.enabled });
|
|
752
|
+
}
|
|
753
|
+
json(res, 404, { ok: false, error: `未知路由 ${sub}` });
|
|
754
|
+
} catch (error) {
|
|
755
|
+
json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
scope.effect(() => {
|
|
759
|
+
const disposeRoutes = scope.webServer.register({ kind: 'prefix', path: '/subagent-profiles', handler });
|
|
760
|
+
return () => disposeRoutes();
|
|
761
|
+
}, 'dsh-subagent-profile: settings routes');
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// C: subagent-profiles service over the same closure Map — lets the outside
|
|
765
|
+
// world enumerate and extend the registry without touching internals.
|
|
766
|
+
ctx.provide('subagent-profiles', {
|
|
767
|
+
register(profile) {
|
|
768
|
+
if (!profile || typeof profile.id !== 'string' || profile.id.length === 0) {
|
|
769
|
+
throw new Error('subagent-profiles: profile id must be a non-empty string');
|
|
770
|
+
}
|
|
771
|
+
if (profiles.has(profile.id)) {
|
|
772
|
+
throw new Error(`subagent-profiles: profile "${profile.id}" is already registered`);
|
|
773
|
+
}
|
|
774
|
+
const registered = { ...profile };
|
|
775
|
+
profiles.set(profile.id, registered);
|
|
776
|
+
return () => {
|
|
777
|
+
if (profiles.get(profile.id) === registered) profiles.delete(profile.id);
|
|
778
|
+
};
|
|
779
|
+
},
|
|
780
|
+
get(id) {
|
|
781
|
+
return profiles.get(id);
|
|
782
|
+
},
|
|
783
|
+
list() {
|
|
784
|
+
return [...profiles.values()];
|
|
785
|
+
},
|
|
786
|
+
resolve(id) {
|
|
787
|
+
return resolveProfile(id);
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// C: directory section rendering the available profiles (systemPrompt's
|
|
792
|
+
// section `text` accepts a function, as the shipped tool-subagent proves).
|
|
793
|
+
const pluginSystemPrompt = ctx.get('systemPrompt');
|
|
794
|
+
if (pluginSystemPrompt !== undefined) {
|
|
795
|
+
pluginSystemPrompt.section({
|
|
796
|
+
name: 'dispatch:profiles',
|
|
797
|
+
order: 116.5,
|
|
798
|
+
text: () => {
|
|
799
|
+
if (!enabled) return '';
|
|
800
|
+
const rows = [...profiles.values()]
|
|
801
|
+
.filter((p) => p.enabled !== false)
|
|
802
|
+
.map((p) => `- ${p.id}: ${p.description}${p.preset !== undefined ? ` (preset: ${p.preset})` : ''}`);
|
|
803
|
+
return rows.length === 0 ? '' : `Available dispatch profiles (dispatch.profile):\n${rows.join('\n')}`;
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
// Announce the self-installed orchestrator preset so the current agent
|
|
807
|
+
// knows the mode exists and can point the user to it.
|
|
808
|
+
pluginSystemPrompt.section({
|
|
809
|
+
name: 'orchestrator:mode',
|
|
810
|
+
order: 117,
|
|
811
|
+
text: '本机已安装 dsh-subagent-profile 插件的「编排者模式」agent preset:新建会话的预设选择器中可选「编排者模式」。该模式把 Agent 定位为主协调者——拆解任务后按场景用 dispatch(内置 swap-standard=标准编码、researcher=调研检索,可在「子 Agent 方案」设置页自定义)与 subagent/subagent_fork/workflow 委派给子 Agent,再整合结果。preset 文件由插件维护于 ~/.dsh/.agent-presets,安装/升级时自动同步;用户提到「编排者模式 / orchestrator / 主协调模式」时即指本预设,请据此协作。'
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 3. `profile` subagent provider.
|
|
816
|
+
const disposeProvider = ctx.subagents.registerProvider({
|
|
817
|
+
name: 'profile',
|
|
818
|
+
capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
|
|
819
|
+
inheritsParentContext: false,
|
|
820
|
+
async start(request) {
|
|
821
|
+
if (!enabled) {
|
|
822
|
+
throw new Error('dispatch: the subagent-profile plugin is disabled (re-enable it in 设置 → 子 Agent 方案)');
|
|
823
|
+
}
|
|
824
|
+
const profile = request.profile;
|
|
825
|
+
if (profile === undefined) {
|
|
826
|
+
throw new Error('dispatch: request.profile is missing (the dispatch tool must resolve a profile before starting)');
|
|
827
|
+
}
|
|
828
|
+
const parent = request.parent;
|
|
829
|
+
// F9: capture the delegation policy synchronously, before the first
|
|
830
|
+
// await — a later parent switch belongs to the parent's future, not to
|
|
831
|
+
// this child (shipped captureDelegatedPolicyOverrides). Passed to setup
|
|
832
|
+
// through the closure.
|
|
833
|
+
const delegated = captureDelegatedPolicyOverrides(parent);
|
|
834
|
+
// F6: authoritative preset whitelist check against the runtime roster.
|
|
835
|
+
const whitelist = new Set(await resolveWhitelist(parent.ctx));
|
|
836
|
+
if (typeof profile.preset === 'string' && profile.preset !== 'inherit' && !whitelist.has(profile.preset)) {
|
|
837
|
+
throw new Error(`dispatch: preset "${profile.preset}" is not in the target-preset whitelist`);
|
|
838
|
+
}
|
|
839
|
+
// F5: authoritative cost guard (runtime-derived; skipped when llm is absent).
|
|
840
|
+
await assertCostGuard(parent, profile);
|
|
841
|
+
// Delegation depth: shipped helpers — assert the cap value, then resolve
|
|
842
|
+
// the child depth (parent floor + 1) and enforce the cap.
|
|
843
|
+
assertSubagentMaxDepth(profile.maxDepth);
|
|
844
|
+
const childDepth = resolveChildDepth(parent, profile.maxDepth);
|
|
845
|
+
const childId = randomUUID();
|
|
846
|
+
const parentAgentPresets = parent.ctx.get('agentPresets');
|
|
847
|
+
const parentComposed = parentAgentPresets !== undefined ? parentAgentPresets.composedPreset(parent.ctx) : undefined;
|
|
848
|
+
const swapPreset = typeof profile.preset === 'string' && profile.preset !== 'inherit' && profile.preset !== parentComposed;
|
|
849
|
+
// F8: agentPreset is recorded only when a preset roster exists
|
|
850
|
+
// (non-rosterless), otherwise omitted entirely. This meta is CUSTOM —
|
|
851
|
+
// not the shipped childSessionMeta — because a swap records
|
|
852
|
+
// profile.preset instead of the parent's composedPreset.
|
|
853
|
+
const meta = {
|
|
854
|
+
...(parent.session.header.cwd !== undefined ? { cwd: parent.session.header.cwd } : {}),
|
|
855
|
+
...(parentAgentPresets !== undefined
|
|
856
|
+
? swapPreset
|
|
857
|
+
? { agentPreset: profile.preset }
|
|
858
|
+
: parentComposed !== undefined
|
|
859
|
+
? { agentPreset: parentComposed }
|
|
860
|
+
: {}
|
|
861
|
+
: {}),
|
|
862
|
+
parentSession: parent.session.header.id,
|
|
863
|
+
origin: 'subagent',
|
|
864
|
+
delegationDepth: childDepth
|
|
865
|
+
};
|
|
866
|
+
// agentOptions: shipped resolveChildAgentOptions — parent route inherited
|
|
867
|
+
// unless the profile overrides provider/model/maxTokens, stamped with the
|
|
868
|
+
// child's own delegation depth.
|
|
869
|
+
const agentOptions = resolveChildAgentOptions(parent, {
|
|
870
|
+
...(profile.provider !== undefined ? { provider: profile.provider } : {}),
|
|
871
|
+
...(profile.model !== undefined ? { model: profile.model } : {}),
|
|
872
|
+
...(profile.maxTokens !== undefined ? { maxTokens: profile.maxTokens } : {})
|
|
873
|
+
}, childDepth);
|
|
874
|
+
if (request.signal !== undefined && request.signal.aborted) {
|
|
875
|
+
throw new Error('dispatch: subagent request was aborted before child publication');
|
|
876
|
+
}
|
|
877
|
+
const handle = await parent.ctx.agents.create({
|
|
878
|
+
sessionId: childId,
|
|
879
|
+
meta,
|
|
880
|
+
agentOptions,
|
|
881
|
+
signal: request.signal,
|
|
882
|
+
setup: async (childCtx) => {
|
|
883
|
+
// ① Preset composition: explicit swap mounts the target preset;
|
|
884
|
+
// otherwise compose from the parent. E: rosterless + explicit
|
|
885
|
+
// swap fails loud instead of silently degrading. This is CUSTOM —
|
|
886
|
+
// not the shipped applyChildComposition, which only composes from
|
|
887
|
+
// the parent.
|
|
888
|
+
const childPresets = childCtx.get('agentPresets');
|
|
889
|
+
if (swapPreset) {
|
|
890
|
+
if (childPresets === undefined) {
|
|
891
|
+
throw new Error('dispatch: cannot swap preset in a rosterless deployment');
|
|
892
|
+
}
|
|
893
|
+
await childPresets.mount(childCtx, profile.preset);
|
|
894
|
+
} else if (childPresets !== undefined) {
|
|
895
|
+
childPresets.composeFrom(childCtx, parent.ctx);
|
|
896
|
+
}
|
|
897
|
+
// ② Tool intersection (safety gate 1): parent set ∩ child set, minus
|
|
898
|
+
// run_code, minus deny, then narrowed by allow when present.
|
|
899
|
+
const parentNames = new Set(parent.ctx.tools.schemas(parent).map((schema) => schema.name));
|
|
900
|
+
const childNames = childCtx.tools.schemas(childCtx.agent).map((schema) => schema.name);
|
|
901
|
+
let effective = childNames.filter((name) =>
|
|
902
|
+
parentNames.has(name) &&
|
|
903
|
+
name !== 'run_code' &&
|
|
904
|
+
!(profile.toolFilter !== undefined && profile.toolFilter.deny !== undefined && profile.toolFilter.deny.includes(name))
|
|
905
|
+
);
|
|
906
|
+
if (profile.toolFilter !== undefined && Array.isArray(profile.toolFilter.allow)) {
|
|
907
|
+
effective = effective.filter((name) => profile.toolFilter.allow.includes(name));
|
|
908
|
+
}
|
|
909
|
+
// F4: shipped restrict does NOT throw on allow:[] — fail loud here so
|
|
910
|
+
// the empty-intersection case is explicit (throw => setupAndPublish
|
|
911
|
+
// rolls the creation back).
|
|
912
|
+
if (effective.length === 0) {
|
|
913
|
+
throw new Error('dispatch: child tool intersection is empty (zero tools)');
|
|
914
|
+
}
|
|
915
|
+
// restrict throws on unknown/scope-local/reserved allow sets: wrap
|
|
916
|
+
// in a clean error and rethrow to trigger creation rollback.
|
|
917
|
+
try {
|
|
918
|
+
childCtx.tools.restrict({ allow: effective });
|
|
919
|
+
} catch (error) {
|
|
920
|
+
throw new Error(`dispatch: child tool restriction failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
921
|
+
}
|
|
922
|
+
// ③ Delegation scope declaration (when systemPrompt is available).
|
|
923
|
+
const systemPrompt = childCtx.get('systemPrompt');
|
|
924
|
+
if (systemPrompt !== undefined) {
|
|
925
|
+
systemPrompt.context({ name: 'subagent:delegation', order: 120, text: DELEGATION_CONTEXT });
|
|
926
|
+
}
|
|
927
|
+
// ④ Persona shadow (overrides deployment:persona at order 0).
|
|
928
|
+
if (profile.persona !== undefined && systemPrompt !== undefined) {
|
|
929
|
+
systemPrompt.section({ name: 'deployment:persona', order: 0, text: profile.persona });
|
|
930
|
+
}
|
|
931
|
+
// ⑤ Reasoning-effort injection into every child request.
|
|
932
|
+
if (profile.reasoningEffort !== undefined) {
|
|
933
|
+
childCtx.on('agent/request', async (_payload, next) => {
|
|
934
|
+
const resolved = await next();
|
|
935
|
+
return { ...resolved, reasoningEffort: profile.reasoningEffort };
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
// ⑥ Descriptor append inside the child's first turn.
|
|
939
|
+
let appended = false;
|
|
940
|
+
childCtx.on('agent/pre-step', async ({ agent }, next) => {
|
|
941
|
+
const decision = await next();
|
|
942
|
+
if (!appended && decision.kind === 'enter') {
|
|
943
|
+
appended = true;
|
|
944
|
+
agent.session.append('subagent/descriptor', request.descriptor);
|
|
945
|
+
}
|
|
946
|
+
return decision;
|
|
947
|
+
});
|
|
948
|
+
// ⑦ Delegation policy appends (shipped helper: sandbox/mode when the
|
|
949
|
+
// parent has an explicit override, approval/policy pinned 'never').
|
|
950
|
+
appendDelegatedPolicyOverrides(childCtx.agent.session, delegated);
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
// F3: post-publication cancellation wiring (drivePublishedRun): the
|
|
954
|
+
// caller signal cancels the child and the result closure skips the
|
|
955
|
+
// followup when already cancelled.
|
|
956
|
+
const child = handle.agent;
|
|
957
|
+
const boundary = child.session.events.length;
|
|
958
|
+
const flags = { cancelled: false };
|
|
959
|
+
const onAbort = () => {
|
|
960
|
+
flags.cancelled = true;
|
|
961
|
+
child.cancel({ kind: 'parent' });
|
|
962
|
+
};
|
|
963
|
+
const signal = request.signal;
|
|
964
|
+
if (signal !== undefined) {
|
|
965
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
966
|
+
if (signal.aborted) onAbort();
|
|
967
|
+
}
|
|
968
|
+
const result = (async () => {
|
|
969
|
+
try {
|
|
970
|
+
if (!flags.cancelled) {
|
|
971
|
+
child.followup(createUserMessage({ content: request.prompt, source: { kind: 'user' } }));
|
|
972
|
+
await child.whenIdle();
|
|
973
|
+
}
|
|
974
|
+
const settled = readResult(child, boundary, flags.cancelled);
|
|
975
|
+
// Decision-level log at result settlement (readResult, before return).
|
|
976
|
+
ctx.logger.info('[dsh-subagent-profile] child:', JSON.stringify({ childId, preset: profile.preset ?? 'inherit', swapPreset, stopReason: settled.stopReason }));
|
|
977
|
+
return settled;
|
|
978
|
+
} finally {
|
|
979
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort);
|
|
980
|
+
}
|
|
981
|
+
})();
|
|
982
|
+
return {
|
|
983
|
+
id: childId,
|
|
984
|
+
localAgent: child,
|
|
985
|
+
result,
|
|
986
|
+
async dispose() {
|
|
987
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort);
|
|
988
|
+
flags.cancelled = true;
|
|
989
|
+
const settled = await Promise.allSettled([handle.dispose(), result]);
|
|
990
|
+
if (settled[0].status === 'rejected') throw settled[0].reason;
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
},
|
|
994
|
+
async prepareContinuable() {
|
|
995
|
+
return {};
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
if (typeof disposeProvider === 'function') ctx.effect(() => disposeProvider);
|
|
999
|
+
|
|
1000
|
+
// 4. `dispatch` tool — bundle registration: the dynamic-plugin harness pair
|
|
1001
|
+
// (harness.defineTool/harness.registerTool) does not exist in a bundle, so
|
|
1002
|
+
// this uses the shipped ctx.tools.register + imported defineTool. The tool
|
|
1003
|
+
// is registered dynamically so the settings switch can unregister it at
|
|
1004
|
+
// runtime (disappearing from the model's tool list) without a restart.
|
|
1005
|
+
const dispatchTool = defineTool({
|
|
1006
|
+
name: 'dispatch',
|
|
1007
|
+
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.',
|
|
1008
|
+
parameters: {
|
|
1009
|
+
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.' },
|
|
1010
|
+
preset: { type: 'string', description: 'Explicit target preset override; must be a system-trust preset of this runtime.' },
|
|
1011
|
+
model: { type: 'string', description: 'Explicit model override for the child.' },
|
|
1012
|
+
provider: { type: 'string', description: 'Explicit provider override for the child.' },
|
|
1013
|
+
reasoningEffort: { type: 'string', description: 'Explicit reasoning-effort override injected into every child request.' },
|
|
1014
|
+
persona: { type: 'string', description: 'Persona text shadowing the child deployment:persona section.' },
|
|
1015
|
+
toolFilter: {
|
|
1016
|
+
type: 'object',
|
|
1017
|
+
// F1: DSL object parameters reject unknown keys by default, so the
|
|
1018
|
+
// toolFilter object must close its schema or defineTool throws at
|
|
1019
|
+
// apply time and the plugin fails to load.
|
|
1020
|
+
additionalProperties: false,
|
|
1021
|
+
description: 'Extra tool whitelist intersection for the child (intersected with the parent tool set).',
|
|
1022
|
+
properties: {
|
|
1023
|
+
allow: { type: 'array', items: { type: 'string' }, description: 'When present, only these tool names are kept.' },
|
|
1024
|
+
deny: { type: 'array', items: { type: 'string' }, description: 'These tool names are always removed.' }
|
|
1025
|
+
}
|
|
1026
|
+
},
|
|
1027
|
+
maxTokens: { type: 'number', description: 'Explicit max-tokens budget for the child.' },
|
|
1028
|
+
maxDepth: { type: 'number', description: 'Absolute delegation-depth cap for this child.' },
|
|
1029
|
+
run_in_background: { type: 'boolean', description: '异步 one-shot:走 jobs.start 包 start(),返回 jobId;仍单轮即弃,非 continuable' },
|
|
1030
|
+
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.' },
|
|
1031
|
+
prompt: { type: 'string', required: true, description: 'The complete, self-contained task for the child (it does not see this conversation).' }
|
|
1032
|
+
},
|
|
1033
|
+
output: {
|
|
1034
|
+
schema: {
|
|
1035
|
+
// D: observability metadata on every result. OneOf covers the
|
|
1036
|
+
// background variant (kind/jobId) and the foreground variant (output),
|
|
1037
|
+
// both closed and both carrying the effective delegation values.
|
|
1038
|
+
oneOf: [
|
|
1039
|
+
{
|
|
1040
|
+
type: 'object',
|
|
1041
|
+
additionalProperties: false,
|
|
1042
|
+
properties: {
|
|
1043
|
+
kind: { type: 'string', required: true, const: 'background' },
|
|
1044
|
+
jobId: { type: 'string', required: true },
|
|
1045
|
+
profile: { type: 'string' },
|
|
1046
|
+
preset: { type: 'string' },
|
|
1047
|
+
provider: { type: 'string' },
|
|
1048
|
+
model: { type: 'string' },
|
|
1049
|
+
reasoningEffort: { type: 'string' }
|
|
1050
|
+
}
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
type: 'object',
|
|
1054
|
+
additionalProperties: false,
|
|
1055
|
+
properties: {
|
|
1056
|
+
kind: { type: 'string', required: true, const: 'continuable' },
|
|
1057
|
+
subagentId: { type: 'string', required: true },
|
|
1058
|
+
profile: { type: 'string' },
|
|
1059
|
+
preset: { type: 'string' },
|
|
1060
|
+
provider: { type: 'string' },
|
|
1061
|
+
model: { type: 'string' },
|
|
1062
|
+
reasoningEffort: { type: 'string' }
|
|
1063
|
+
}
|
|
1064
|
+
},
|
|
1065
|
+
{
|
|
1066
|
+
type: 'object',
|
|
1067
|
+
additionalProperties: false,
|
|
1068
|
+
properties: {
|
|
1069
|
+
output: { type: 'string', required: true },
|
|
1070
|
+
profile: { type: 'string' },
|
|
1071
|
+
preset: { type: 'string' },
|
|
1072
|
+
provider: { type: 'string' },
|
|
1073
|
+
model: { type: 'string' },
|
|
1074
|
+
reasoningEffort: { type: 'string' }
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
]
|
|
1078
|
+
},
|
|
1079
|
+
render: (_args, value) => [{
|
|
1080
|
+
type: 'text',
|
|
1081
|
+
text: value.kind === 'background'
|
|
1082
|
+
? `[dispatch] background job ${value.jobId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}`
|
|
1083
|
+
: value.kind === 'continuable'
|
|
1084
|
+
? `[dispatch] started subagent ${value.subagentId} · profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}`
|
|
1085
|
+
: `[dispatch] profile=${value.profile} · preset=${value.preset} · provider=${value.provider} · model=${value.model} · reasoningEffort=${value.reasoningEffort}\n\n${value.output}`
|
|
1086
|
+
}]
|
|
1087
|
+
},
|
|
1088
|
+
isConcurrencySafe: () => true,
|
|
1089
|
+
async execute(args, exec) {
|
|
1090
|
+
const parent = exec.agent;
|
|
1091
|
+
if (!parent) throw new Error('dispatch requires calling agent');
|
|
1092
|
+
// Resolve the base profile (side channel), then overlay explicit args.
|
|
1093
|
+
const base = args.profile !== undefined ? resolveProfile(args.profile) : {};
|
|
1094
|
+
const merged = { ...base };
|
|
1095
|
+
for (const key of ['preset', 'model', 'provider', 'reasoningEffort', 'persona', 'toolFilter', 'maxTokens', 'maxDepth']) {
|
|
1096
|
+
if (args[key] !== undefined) merged[key] = args[key];
|
|
1097
|
+
}
|
|
1098
|
+
// Pre-check: explicit concrete preset must be in the runtime-derived
|
|
1099
|
+
// whitelist (F6); a preset equal to the parent's composed preset is
|
|
1100
|
+
// rewritten to 'inherit' (no swap).
|
|
1101
|
+
if (typeof merged.preset === 'string' && merged.preset !== 'inherit') {
|
|
1102
|
+
const whitelist = new Set(await resolveWhitelist(parent.ctx));
|
|
1103
|
+
if (!whitelist.has(merged.preset)) {
|
|
1104
|
+
throw new Error(`dispatch: preset "${merged.preset}" is not in the target-preset whitelist`);
|
|
1105
|
+
}
|
|
1106
|
+
const parentPresets = parent.ctx.get('agentPresets');
|
|
1107
|
+
const parentComposed = parentPresets !== undefined ? parentPresets.composedPreset(parent.ctx) : undefined;
|
|
1108
|
+
if (merged.preset === parentComposed) merged.preset = 'inherit';
|
|
1109
|
+
}
|
|
1110
|
+
// F5: cost guard (runtime-derived; skipped when llm is absent).
|
|
1111
|
+
await assertCostGuard(parent, merged);
|
|
1112
|
+
// D: effective delegation values for observability.
|
|
1113
|
+
const meta = {
|
|
1114
|
+
profile: args.profile ?? '(inline)',
|
|
1115
|
+
preset: merged.preset ?? 'inherit',
|
|
1116
|
+
provider: merged.provider ?? parent.options.provider ?? '(parent)',
|
|
1117
|
+
model: merged.model ?? parent.options.model ?? '(parent)',
|
|
1118
|
+
reasoningEffort: merged.reasoningEffort ?? '(default)'
|
|
1119
|
+
};
|
|
1120
|
+
const request = {
|
|
1121
|
+
label: String(args.prompt ?? '').slice(0, 60),
|
|
1122
|
+
prompt: [{ type: 'text', text: args.prompt }],
|
|
1123
|
+
parent,
|
|
1124
|
+
signal: exec.signal,
|
|
1125
|
+
profile: merged,
|
|
1126
|
+
...(merged.persona !== undefined ? { persona: merged.persona } : {}),
|
|
1127
|
+
...(merged.toolFilter !== undefined ? { toolFilter: merged.toolFilter } : {}),
|
|
1128
|
+
...(merged.maxDepth !== undefined ? { maxDepth: merged.maxDepth } : {})
|
|
1129
|
+
};
|
|
1130
|
+
// Decision-level log: resolved effective delegation inputs, after the
|
|
1131
|
+
// cost guard and after request assembly, before dispatch.
|
|
1132
|
+
ctx.logger.info('[dsh-subagent-profile] dispatch:', JSON.stringify({
|
|
1133
|
+
profile: args.profile ?? '(inline)',
|
|
1134
|
+
preset: merged.preset ?? 'inherit',
|
|
1135
|
+
provider: merged.provider ?? parent.options.provider ?? '(parent)',
|
|
1136
|
+
model: merged.model ?? parent.options.model ?? '(parent)',
|
|
1137
|
+
reasoningEffort: merged.reasoningEffort ?? '(default)',
|
|
1138
|
+
maxDepth: merged.maxDepth ?? null,
|
|
1139
|
+
background: args.run_in_background === true,
|
|
1140
|
+
continuable: args.continuable === true
|
|
1141
|
+
}));
|
|
1142
|
+
// Continuable (durable) path — startContinuable publishes a persistent
|
|
1143
|
+
// child and returns its durable id; the official send_message tool drives
|
|
1144
|
+
// later turns. Treated first so a caller asking for both background and
|
|
1145
|
+
// continuable gets the continuable child.
|
|
1146
|
+
if (args.continuable === true) {
|
|
1147
|
+
if (args.run_in_background === true) {
|
|
1148
|
+
ctx.logger.warn('[dsh-subagent-profile] dispatch: both continuable and run_in_background are true; continuable takes precedence');
|
|
1149
|
+
}
|
|
1150
|
+
// 已知降级:continuable 标准路径不支持 preset swap 和 reasoningEffort(subagent 包的 SubagentStartRequest 无 preset 字段、AgentOptions 无 reasoningEffort 字段)
|
|
1151
|
+
if (merged.preset !== undefined && merged.preset !== 'inherit') {
|
|
1152
|
+
ctx.logger.warn(`[dsh-subagent-profile] continuable mode cannot swap preset; ignoring "${merged.preset}" (child inherits the parent preset)`);
|
|
1153
|
+
}
|
|
1154
|
+
if (merged.reasoningEffort !== undefined) {
|
|
1155
|
+
ctx.logger.warn(`[dsh-subagent-profile] continuable mode cannot set reasoningEffort; ignoring "${merged.reasoningEffort}"`);
|
|
1156
|
+
}
|
|
1157
|
+
const hasAgentOptions = merged.provider !== undefined || merged.model !== undefined || merged.maxTokens !== undefined;
|
|
1158
|
+
const { childId } = await ctx.subagents.startContinuable({
|
|
1159
|
+
provider: 'profile',
|
|
1160
|
+
label: String(args.prompt ?? '').slice(0, 60),
|
|
1161
|
+
request: {
|
|
1162
|
+
prompt: [{ type: 'text', text: args.prompt }],
|
|
1163
|
+
parent,
|
|
1164
|
+
...(hasAgentOptions ? { agentOptions: {
|
|
1165
|
+
...(merged.provider !== undefined ? { provider: merged.provider } : {}),
|
|
1166
|
+
...(merged.model !== undefined ? { model: merged.model } : {}),
|
|
1167
|
+
...(merged.maxTokens !== undefined ? { maxTokens: merged.maxTokens } : {})
|
|
1168
|
+
} } : {}),
|
|
1169
|
+
...(merged.persona !== undefined ? { persona: merged.persona } : {}),
|
|
1170
|
+
...(merged.toolFilter !== undefined ? { toolFilter: merged.toolFilter } : {}),
|
|
1171
|
+
...(merged.maxDepth !== undefined ? { maxDepth: merged.maxDepth } : {})
|
|
1172
|
+
},
|
|
1173
|
+
signal: exec.signal
|
|
1174
|
+
});
|
|
1175
|
+
// Continuable drops the profile's preset swap and reasoningEffort (the
|
|
1176
|
+
// child inherits the parent preset), so the observability meta must
|
|
1177
|
+
// report what actually took effect, not the requested-but-ignored values.
|
|
1178
|
+
return { kind: 'continuable', subagentId: childId, profile: meta.profile, preset: 'inherit', provider: meta.provider, model: meta.model };
|
|
1179
|
+
}
|
|
1180
|
+
// A: background one-shot (job) path — jobs.start wraps start() with a
|
|
1181
|
+
// native AbortController (a Node global in a bundle; the dynamic-plugin
|
|
1182
|
+
// sandbox needed the hand-rolled shim instead); still one turn, not
|
|
1183
|
+
// continuable.
|
|
1184
|
+
if (args.run_in_background === true) {
|
|
1185
|
+
const jobs = ctx.get('jobs');
|
|
1186
|
+
if (jobs === undefined) {
|
|
1187
|
+
throw new Error('dispatch: background jobs unavailable (load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs)');
|
|
1188
|
+
}
|
|
1189
|
+
const jobId = jobs.start({
|
|
1190
|
+
kind: 'subagent',
|
|
1191
|
+
label: String(args.prompt ?? '').slice(0, 60),
|
|
1192
|
+
owner: parent,
|
|
1193
|
+
run: () => {
|
|
1194
|
+
const controller = new AbortController();
|
|
1195
|
+
return {
|
|
1196
|
+
cancel: (reason) => controller.abort(reason ?? 'dispatch: background subagent task killed'),
|
|
1197
|
+
done: settleStart(ctx.subagents.start('profile', { ...request, signal: controller.signal }), controller.signal, meta)
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
return { kind: 'background', jobId, ...meta };
|
|
1202
|
+
}
|
|
1203
|
+
// Foreground: collect, always release the handle (E: dispose even when
|
|
1204
|
+
// run.result rejects), then fail loud on a non-completed stop reason.
|
|
1205
|
+
const run = await ctx.subagents.start('profile', request);
|
|
1206
|
+
let result;
|
|
1207
|
+
try {
|
|
1208
|
+
result = await run.result;
|
|
1209
|
+
} finally {
|
|
1210
|
+
await run.dispose().catch(() => {});
|
|
1211
|
+
}
|
|
1212
|
+
// F2: a non-'completed' stop reason is a failure; attach the child's
|
|
1213
|
+
// partial output text (withPartialText style).
|
|
1214
|
+
const failure = stopReasonError(result);
|
|
1215
|
+
if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
|
|
1216
|
+
return { output: textFrom(result.output), ...meta };
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
// Register the tool only while enabled; unregister it the moment the switch
|
|
1220
|
+
// turns off so it disappears from the model's tool list without a restart.
|
|
1221
|
+
let disposeTool;
|
|
1222
|
+
function syncTool() {
|
|
1223
|
+
if (enabled && disposeTool === undefined) {
|
|
1224
|
+
disposeTool = ctx.tools.register(dispatchTool);
|
|
1225
|
+
} else if (!enabled && disposeTool !== undefined) {
|
|
1226
|
+
const dispose = disposeTool;
|
|
1227
|
+
disposeTool = undefined;
|
|
1228
|
+
dispose();
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
syncTool();
|
|
1232
|
+
ctx.effect(() => () => {
|
|
1233
|
+
if (disposeTool !== undefined) {
|
|
1234
|
+
const dispose = disposeTool;
|
|
1235
|
+
disposeTool = undefined;
|
|
1236
|
+
dispose();
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
1239
|
+
}
|