dsh-subagent-profile 0.1.0 → 0.2.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 +34 -3
- package/README.zh.md +33 -2
- package/cordis.patch.yml +2 -2
- package/index.mjs +359 -172
- package/lib/client.js +250 -56
- package/lib/pure.mjs +331 -0
- package/lib/shims.mjs +215 -0
- package/package.json +7 -3
- package/presets/orchestrator/agent.cordis.yml +7 -9
package/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// index.mjs — dsh-subagent-profile host
|
|
1
|
+
// index.mjs — dsh-subagent-profile host side (formal plugin bundle).
|
|
2
2
|
// Converted from prototype/subagent-profile/host.plugin.js (the dynamic-plugin
|
|
3
3
|
// `code.host` body) with import slimming: the inline foldConsumedWork /
|
|
4
4
|
// accountsForClaim / lastAssistantContent / uuid / AbortController-shim are
|
|
@@ -8,21 +8,21 @@
|
|
|
8
8
|
// in README.md.
|
|
9
9
|
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { basename, dirname, join, relative } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { foldConsumedWork } from '@deepseek-ai/dsh-agent';
|
|
16
15
|
import {
|
|
17
16
|
appendDelegatedPolicyOverrides,
|
|
18
17
|
assertSubagentMaxDepth,
|
|
19
18
|
captureDelegatedPolicyOverrides,
|
|
20
|
-
|
|
19
|
+
createUserMessage,
|
|
20
|
+
defineTool,
|
|
21
|
+
readResult,
|
|
21
22
|
resolveChildAgentOptions,
|
|
22
23
|
resolveChildDepth,
|
|
23
|
-
} from '
|
|
24
|
-
import {
|
|
25
|
-
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
24
|
+
} from './lib/shims.mjs';
|
|
25
|
+
import { textFrom, stopReasonError, withPartialText, sanitizeProfile, GUIDANCE_PREFIX, assertHardLimits, computeContinuableAllow, pruneBlocks, assertResultSchemaConsistency } from './lib/pure.mjs';
|
|
26
26
|
|
|
27
27
|
export const name = 'dsh-subagent-profile';
|
|
28
28
|
export const inject = ['subagents', 'tools', 'agents'];
|
|
@@ -196,67 +196,52 @@ function persistEnabled(enabled) {
|
|
|
196
196
|
|
|
197
197
|
// --- module-level helpers (kept inline; the packages do not export them) ---
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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);
|
|
216
217
|
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
}
|
|
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;
|
|
228
224
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 拒绝(可在配置中显式开启兼容模式)');
|
|
238
242
|
}
|
|
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
243
|
|
|
253
|
-
//
|
|
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;
|
|
244
|
+
// ④ provider 注册校验(目录非空时才能判定「不在目录」)。
|
|
260
245
|
if (typeof profile.provider === 'string' && profile.provider.length > 0) {
|
|
261
246
|
const providers = await llm.listProviders();
|
|
262
247
|
if (!(providers ?? []).some((provider) => provider && provider.id === profile.provider)) {
|
|
@@ -265,13 +250,14 @@ async function assertCostGuard(parent, profile) {
|
|
|
265
250
|
}
|
|
266
251
|
const effectiveProvider = profile.provider !== undefined ? profile.provider : parent.options.provider;
|
|
267
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".
|
|
268
260
|
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
261
|
let models;
|
|
276
262
|
try {
|
|
277
263
|
models = await llm.listModels(effectiveProvider);
|
|
@@ -280,12 +266,11 @@ async function assertCostGuard(parent, profile) {
|
|
|
280
266
|
}
|
|
281
267
|
const listed = models ?? [];
|
|
282
268
|
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
269
|
if (listed.length > 0 && !known) {
|
|
286
270
|
throw new Error(`dispatch: model "${profile.model}" is not advertised by provider "${String(effectiveProvider)}"`);
|
|
287
271
|
}
|
|
288
272
|
}
|
|
273
|
+
// ⑥ reasoningEffort 校验。
|
|
289
274
|
if (typeof profile.reasoningEffort === 'string' && profile.reasoningEffort.length > 0) {
|
|
290
275
|
try {
|
|
291
276
|
await llm.resolveCallConfig({ provider: effectiveProvider, model: effectiveModel, reasoningEffort: profile.reasoningEffort });
|
|
@@ -293,19 +278,17 @@ async function assertCostGuard(parent, profile) {
|
|
|
293
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)}`);
|
|
294
279
|
}
|
|
295
280
|
}
|
|
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
281
|
}
|
|
303
282
|
|
|
304
283
|
// Settle one background one-shot run into a job outcome with the same
|
|
305
284
|
// observability metadata the foreground path reports. Non-completed stop reasons
|
|
306
285
|
// become failed (aborted => killed, shipped vocabulary) with partial output
|
|
307
286
|
// attached; hard failures never reject the job.
|
|
308
|
-
|
|
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) {
|
|
309
292
|
let run;
|
|
310
293
|
try {
|
|
311
294
|
run = await start;
|
|
@@ -314,7 +297,7 @@ async function settleStart(start, signal, meta) {
|
|
|
314
297
|
if (failure !== undefined) {
|
|
315
298
|
return { status: result.stopReason === 'aborted' ? 'killed' : 'failed', detail: withPartialText(failure, result.output), ...meta };
|
|
316
299
|
}
|
|
317
|
-
return { status: 'completed', output: textFrom(result.output), ...meta };
|
|
300
|
+
return { status: 'completed', output: textFrom(prune(result.output)), ...meta };
|
|
318
301
|
} catch (error) {
|
|
319
302
|
return signal.aborted ? { status: 'killed', ...meta } : { status: 'failed', detail: String(error), ...meta };
|
|
320
303
|
} finally {
|
|
@@ -460,32 +443,73 @@ export async function apply(ctx) {
|
|
|
460
443
|
// add/remove HTTP routes rewrite the file. Persistence is an enhancement, not
|
|
461
444
|
// a hard dependency: any failure only warns and the builtin seeds work.
|
|
462
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;
|
|
463
452
|
function loadProfiles() {
|
|
464
453
|
if (!existsSync(profilesFile)) return;
|
|
465
454
|
try {
|
|
466
455
|
const parsed = JSON.parse(readFileSync(profilesFile, 'utf8'));
|
|
467
|
-
|
|
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
|
+
}
|
|
468
479
|
let loaded = 0;
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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);
|
|
475
498
|
}
|
|
476
499
|
continue;
|
|
477
500
|
}
|
|
478
|
-
const existing = profiles.get(
|
|
501
|
+
const existing = profiles.get(clean.id);
|
|
479
502
|
if (existing !== undefined && existing.builtin === true) {
|
|
480
|
-
profiles.set(
|
|
503
|
+
profiles.set(clean.id, { ...clean, builtin: true, persisted: true });
|
|
481
504
|
} else {
|
|
482
|
-
profiles.set(
|
|
505
|
+
profiles.set(clean.id, { ...clean, persisted: true });
|
|
483
506
|
}
|
|
484
507
|
loaded++;
|
|
485
508
|
}
|
|
486
509
|
// Silent success: report how many persisted profiles came in (skipped
|
|
487
510
|
// when none — a missing/empty file is the normal first boot).
|
|
488
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)`);
|
|
489
513
|
} catch (error) {
|
|
490
514
|
ctx.logger.warn('[dsh-subagent-profile] persisted profile load failed:', error instanceof Error ? error.message : String(error));
|
|
491
515
|
}
|
|
@@ -507,25 +531,40 @@ export async function apply(ctx) {
|
|
|
507
531
|
ctx.logger.warn('[dsh-subagent-profile] preset sync failed:', error instanceof Error ? error.message : String(error));
|
|
508
532
|
}
|
|
509
533
|
|
|
510
|
-
/**
|
|
534
|
+
/**
|
|
535
|
+
* Persist every `persisted: true` profile plus builtin-delete tombstones.
|
|
536
|
+
* Atomic write (SPEC §12.2): write `<profilesFile>.tmp` in the same directory,
|
|
537
|
+
* then `renameSync` over the target (a crash leaves the old file intact, never
|
|
538
|
+
* a truncated one). Fail-visible (D5/B1): a failure does NOT throw and does NOT
|
|
539
|
+
* roll back the in-memory `profiles` Map — it returns `{ persisted: false }` so
|
|
540
|
+
* the caller can signal "已保存但未持久化" while the in-memory state keeps
|
|
541
|
+
* driving this process. Always writes the v2 envelope shape.
|
|
542
|
+
*/
|
|
511
543
|
function persistProfiles() {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
clean[key] = value;
|
|
520
|
-
}
|
|
521
|
-
entries.push(clean);
|
|
522
|
-
}
|
|
523
|
-
for (const id of deletedBuiltins) {
|
|
524
|
-
entries.push({ id, builtin: true, deleted: true });
|
|
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;
|
|
525
551
|
}
|
|
526
|
-
|
|
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 };
|
|
527
563
|
} catch (error) {
|
|
564
|
+
// Best-effort cleanup of the partial tmp file (rename never ran).
|
|
565
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
528
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) };
|
|
529
568
|
}
|
|
530
569
|
}
|
|
531
570
|
|
|
@@ -553,6 +592,16 @@ export async function apply(ctx) {
|
|
|
553
592
|
});
|
|
554
593
|
req.on('error', reject);
|
|
555
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
|
+
});
|
|
556
605
|
const listClean = () => [...profiles.values()].map((profile) => {
|
|
557
606
|
const clean = {};
|
|
558
607
|
for (const [key, value] of Object.entries(profile)) if (value !== undefined && key !== 'persisted') clean[key] = value;
|
|
@@ -677,32 +726,44 @@ export async function apply(ctx) {
|
|
|
677
726
|
if (typeof profile.id !== 'string' || profile.id.length === 0) {
|
|
678
727
|
return json(res, 400, { ok: false, error: 'subagent-profiles: profile id must be a non-empty string' });
|
|
679
728
|
}
|
|
680
|
-
|
|
681
|
-
|
|
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);
|
|
682
739
|
const isBuiltin = (existing !== undefined && existing.builtin === true) || seed !== undefined;
|
|
683
740
|
// Merge (not replace): start from the existing profile — or its seed
|
|
684
741
|
// 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
|
-
|
|
687
|
-
clean
|
|
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;
|
|
688
747
|
for (const key of ['name', 'description', 'preset', 'provider', 'model', 'reasoningEffort', 'persona', 'enabled']) {
|
|
689
|
-
if (
|
|
690
|
-
if (
|
|
691
|
-
|
|
748
|
+
if (clean[key] === undefined) continue; // 未传:保留 existing 原值
|
|
749
|
+
if (clean[key] === '' || clean[key] === null) { delete merged[key]; continue; } // 空:清除字段
|
|
750
|
+
merged[key] = clean[key];
|
|
692
751
|
}
|
|
693
|
-
// toolFilter 特殊处理:前端改成多选下拉后总是传数组,空数组 =
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const
|
|
698
|
-
if (
|
|
699
|
-
|
|
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
|
+
}
|
|
700
762
|
}
|
|
701
|
-
if (
|
|
702
|
-
profiles.set(
|
|
703
|
-
deletedBuiltins.delete(
|
|
704
|
-
persistProfiles();
|
|
705
|
-
return json(res, 200, { ok: true, id: profile.id });
|
|
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());
|
|
706
767
|
}
|
|
707
768
|
if (req.method === 'POST' && sub === '/remove') {
|
|
708
769
|
const body = await readBody(req);
|
|
@@ -713,8 +774,7 @@ export async function apply(ctx) {
|
|
|
713
774
|
}
|
|
714
775
|
profiles.delete(id);
|
|
715
776
|
if (existing.builtin === true) deletedBuiltins.add(id);
|
|
716
|
-
persistProfiles();
|
|
717
|
-
return json(res, 200, { ok: true, id });
|
|
777
|
+
return persistOk(res, { id }, persistProfiles());
|
|
718
778
|
}
|
|
719
779
|
if (req.method === 'POST' && sub === '/reset') {
|
|
720
780
|
const body = await readBody(req);
|
|
@@ -725,16 +785,14 @@ export async function apply(ctx) {
|
|
|
725
785
|
}
|
|
726
786
|
profiles.set(id, { ...seed });
|
|
727
787
|
deletedBuiltins.delete(id);
|
|
728
|
-
persistProfiles();
|
|
729
|
-
return json(res, 200, { ok: true, id });
|
|
788
|
+
return persistOk(res, { id }, persistProfiles());
|
|
730
789
|
}
|
|
731
790
|
if (req.method === 'POST' && sub === '/reset-all') {
|
|
732
791
|
for (const seed of BUILTIN_SEEDS) {
|
|
733
792
|
profiles.set(seed.id, { ...seed });
|
|
734
793
|
deletedBuiltins.delete(seed.id);
|
|
735
794
|
}
|
|
736
|
-
persistProfiles();
|
|
737
|
-
return json(res, 200, { ok: true, count: BUILTIN_SEEDS.length });
|
|
795
|
+
return persistOk(res, { count: BUILTIN_SEEDS.length }, persistProfiles());
|
|
738
796
|
}
|
|
739
797
|
if (req.method === 'POST' && sub === '/set-profile-enabled') {
|
|
740
798
|
const body = await readBody(req);
|
|
@@ -747,12 +805,13 @@ export async function apply(ctx) {
|
|
|
747
805
|
// Persist unconditionally (not just for builtins): a runtime-registered
|
|
748
806
|
// profile's enable/disable must also survive a restart.
|
|
749
807
|
existing.persisted = true;
|
|
750
|
-
persistProfiles();
|
|
751
|
-
return json(res, 200, { ok: true, id, enabled: existing.enabled });
|
|
808
|
+
return persistOk(res, { id, enabled: existing.enabled }, persistProfiles());
|
|
752
809
|
}
|
|
753
810
|
json(res, 404, { ok: false, error: `未知路由 ${sub}` });
|
|
754
811
|
} catch (error) {
|
|
755
|
-
|
|
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: '内部错误,详情见宿主日志' });
|
|
756
815
|
}
|
|
757
816
|
};
|
|
758
817
|
scope.effect(() => {
|
|
@@ -792,23 +851,79 @@ export async function apply(ctx) {
|
|
|
792
851
|
// section `text` accepts a function, as the shipped tool-subagent proves).
|
|
793
852
|
const pluginSystemPrompt = ctx.get('systemPrompt');
|
|
794
853
|
if (pluginSystemPrompt !== undefined) {
|
|
854
|
+
// §8.1 系统提示门控:两段 profile-mode section **只在当前席 Agent 是编排者
|
|
855
|
+
// 预设(orchestrator)**时注入,从而消除从 standard/code/minimal 等不派发
|
|
856
|
+
// 会话上的每请求固定泄漏。
|
|
857
|
+
//
|
|
858
|
+
// —— 门控判据(以源码为准确认,见 test/README.md)——
|
|
859
|
+
// 主判据(preset 特征):`composedPreset(agentCtx) === 'orchestrator'`。这是
|
|
860
|
+
// 判别「是否编排者会话」的可靠信号:dispatch 工具由本 host 行注册、经
|
|
861
|
+
// dsh-tools `schemas(scope)` 的 global 继承起点对**每个** agent 恒可见,因此
|
|
862
|
+
// 「schemas(agent) 含 dispatch」在宿主架构下恒真,**不能**作为主判据(规格
|
|
863
|
+
// 评审意见:schemas 判据降为否决)。
|
|
864
|
+
// 否决(防御,防假阳性):若 `schemas(agent)` **明确不含** dispatch → 必空。
|
|
865
|
+
// 生产环境恒含 dispatch(host-global),此否决在正常路径不触发;它只防御任何
|
|
866
|
+
// 让 dispatch 从 agent 视野消失的宿主行为。
|
|
867
|
+
// 回退:拿不到 agentPresets(composedPreset 不可用)时无法判别当前 Agent 的
|
|
868
|
+
// 组成,保守不注入(无泄漏风险)。
|
|
869
|
+
//
|
|
870
|
+
// section.text(context) 由宿主以 assembleContextFor(agent, signal) =
|
|
871
|
+
// { agent, scope: agent, signal } 调用(@deepseek-ai/dsh-agent),因此 text()
|
|
872
|
+
// 能拿到 context.agent;Agent 实例带 .ctx(dsh-agent-presets 的
|
|
873
|
+
// composedPreset(agentCtx) 正以 agentCtx 作为期望入参),故主判据优先取
|
|
874
|
+
// context.agent.ctx。
|
|
875
|
+
const sectionGatePasses = (context) => {
|
|
876
|
+
if (!enabled) return false;
|
|
877
|
+
// 否决(防御性):schemas(agent) 明确不含 dispatch → 必空。
|
|
878
|
+
if (context !== undefined && context.agent !== undefined) {
|
|
879
|
+
const schemas = ctx.tools.schemas(context.agent);
|
|
880
|
+
if (Array.isArray(schemas) && !schemas.some((s) => s && s.name === 'dispatch')) {
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
// 主判据:preset 特征 —— composedPreset(agentCtx) === 'orchestrator'。
|
|
885
|
+
const agentPresets = ctx.get('agentPresets');
|
|
886
|
+
if (agentPresets !== undefined && typeof agentPresets.composedPreset === 'function') {
|
|
887
|
+
const agentCtx = context !== undefined && context.agent !== undefined && context.agent.ctx !== undefined
|
|
888
|
+
? context.agent.ctx
|
|
889
|
+
: ctx;
|
|
890
|
+
try { return agentPresets.composedPreset(agentCtx) === 'orchestrator'; }
|
|
891
|
+
catch { return false; }
|
|
892
|
+
}
|
|
893
|
+
// 无 agentPresets → 无法判别 → 保守不注入。
|
|
894
|
+
return false;
|
|
895
|
+
};
|
|
795
896
|
pluginSystemPrompt.section({
|
|
796
897
|
name: 'dispatch:profiles',
|
|
797
898
|
order: 116.5,
|
|
798
|
-
text: () => {
|
|
799
|
-
if (!
|
|
899
|
+
text: (context) => {
|
|
900
|
+
if (!sectionGatePasses(context)) return '';
|
|
800
901
|
const rows = [...profiles.values()]
|
|
801
902
|
.filter((p) => p.enabled !== false)
|
|
802
|
-
.map((p) =>
|
|
803
|
-
|
|
903
|
+
.map((p) => {
|
|
904
|
+
// 引号引用:description 套引号;为空时显示占位符(不套引号)。压平
|
|
905
|
+
// 在存储层完成(sanitizeProfile),此处仅负责显示层包裹。
|
|
906
|
+
const desc = typeof p.description === 'string' && p.description.length > 0 ? `"${p.description}"` : '(无描述)';
|
|
907
|
+
return `- ${p.id}: ${desc}${p.preset !== undefined ? ` (preset: ${p.preset})` : ''}`;
|
|
908
|
+
});
|
|
909
|
+
if (rows.length === 0) return '';
|
|
910
|
+
// §8.3 一行行为规则(进门控 profiles section,非常开 persona):用相对
|
|
911
|
+
// 锚点降低「几分钟内可自查完的小任务」被派发的概率(SPEC §8.1 残留分歧
|
|
912
|
+
// 已定:行为规则注入点取门控 profiles section)。
|
|
913
|
+
const note = '- 别把 1-2 步即可自查/可搜完的小事委派出去 —— 几分钟内能自查完的直接做。';
|
|
914
|
+
return `Available dispatch profiles (dispatch.profile):\n${rows.join('\n')}\n${note}`;
|
|
804
915
|
}
|
|
805
916
|
});
|
|
806
917
|
// Announce the self-installed orchestrator preset so the current agent
|
|
807
|
-
// knows the mode exists and can point the user to it.
|
|
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.
|
|
808
920
|
pluginSystemPrompt.section({
|
|
809
921
|
name: 'orchestrator:mode',
|
|
810
922
|
order: 117,
|
|
811
|
-
text:
|
|
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
|
+
}
|
|
812
927
|
});
|
|
813
928
|
}
|
|
814
929
|
|
|
@@ -836,8 +951,9 @@ export async function apply(ctx) {
|
|
|
836
951
|
if (typeof profile.preset === 'string' && profile.preset !== 'inherit' && !whitelist.has(profile.preset)) {
|
|
837
952
|
throw new Error(`dispatch: preset "${profile.preset}" is not in the target-preset whitelist`);
|
|
838
953
|
}
|
|
839
|
-
// F5: authoritative cost guard (runtime-derived;
|
|
840
|
-
|
|
954
|
+
// F5: authoritative cost guard (runtime-derived; hard caps always applied,
|
|
955
|
+
// llm capability gated by allowFailOpen — SPEC §7.3).
|
|
956
|
+
await assertCostGuard(parent, profile, allowFailOpen, ctx.logger);
|
|
841
957
|
// Delegation depth: shipped helpers — assert the cap value, then resolve
|
|
842
958
|
// the child depth (parent floor + 1) and enforce the cap.
|
|
843
959
|
assertSubagentMaxDepth(profile.maxDepth);
|
|
@@ -924,9 +1040,16 @@ export async function apply(ctx) {
|
|
|
924
1040
|
if (systemPrompt !== undefined) {
|
|
925
1041
|
systemPrompt.context({ name: 'subagent:delegation', order: 120, text: DELEGATION_CONTEXT });
|
|
926
1042
|
}
|
|
927
|
-
// ④ Persona shadow (overrides deployment:persona at order 0).
|
|
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).
|
|
928
1047
|
if (profile.persona !== undefined && systemPrompt !== undefined) {
|
|
929
|
-
systemPrompt.section({
|
|
1048
|
+
systemPrompt.section({
|
|
1049
|
+
name: 'deployment:persona',
|
|
1050
|
+
order: 0,
|
|
1051
|
+
text: profile.persona.length > 0 ? `${GUIDANCE_PREFIX}${profile.persona}` : profile.persona,
|
|
1052
|
+
});
|
|
930
1053
|
}
|
|
931
1054
|
// ⑤ Reasoning-effort injection into every child request.
|
|
932
1055
|
if (profile.reasoningEffort !== undefined) {
|
|
@@ -1004,7 +1127,7 @@ export async function apply(ctx) {
|
|
|
1004
1127
|
// runtime (disappearing from the model's tool list) without a restart.
|
|
1005
1128
|
const dispatchTool = defineTool({
|
|
1006
1129
|
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.',
|
|
1130
|
+
description: 'Dispatch a subtask to a derived subagent, optionally overriding its preset, model, provider, reasoning effort, persona, tool whitelist, token budget, or recursion depth. Foreground waits for the result; run_in_background: true starts a background job (single turn); continuable: true starts a durable subagent whose conversation stays available for later turns via the send_message tool. 前瞻:continuable 模式忽略 preset 换用与 reasoningEffort(结果以 ignored 提示)。',
|
|
1008
1131
|
parameters: {
|
|
1009
1132
|
profile: { type: 'string', description: 'Optional profile id from the profile registry (built-ins: swap-standard, researcher, plus any you define in the settings page); omit to inherit the parent preset and tools as-is.' },
|
|
1010
1133
|
preset: { type: 'string', description: 'Explicit target preset override; must be a system-trust preset of this runtime.' },
|
|
@@ -1028,6 +1151,10 @@ export async function apply(ctx) {
|
|
|
1028
1151
|
maxDepth: { type: 'number', description: 'Absolute delegation-depth cap for this child.' },
|
|
1029
1152
|
run_in_background: { type: 'boolean', description: '异步 one-shot:走 jobs.start 包 start(),返回 jobId;仍单轮即弃,非 continuable' },
|
|
1030
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 中期启用,当前不生效)' },
|
|
1031
1158
|
prompt: { type: 'string', required: true, description: 'The complete, self-contained task for the child (it does not see this conversation).' }
|
|
1032
1159
|
},
|
|
1033
1160
|
output: {
|
|
@@ -1035,6 +1162,10 @@ export async function apply(ctx) {
|
|
|
1035
1162
|
// D: observability metadata on every result. OneOf covers the
|
|
1036
1163
|
// background variant (kind/jobId) and the foreground variant (output),
|
|
1037
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 分支 忘补该字段.
|
|
1038
1169
|
oneOf: [
|
|
1039
1170
|
{
|
|
1040
1171
|
type: 'object',
|
|
@@ -1046,7 +1177,8 @@ export async function apply(ctx) {
|
|
|
1046
1177
|
preset: { type: 'string' },
|
|
1047
1178
|
provider: { type: 'string' },
|
|
1048
1179
|
model: { type: 'string' },
|
|
1049
|
-
reasoningEffort: { type: 'string' }
|
|
1180
|
+
reasoningEffort: { type: 'string' },
|
|
1181
|
+
ignored: { type: 'array', items: { type: 'string' } }
|
|
1050
1182
|
}
|
|
1051
1183
|
},
|
|
1052
1184
|
{
|
|
@@ -1059,7 +1191,8 @@ export async function apply(ctx) {
|
|
|
1059
1191
|
preset: { type: 'string' },
|
|
1060
1192
|
provider: { type: 'string' },
|
|
1061
1193
|
model: { type: 'string' },
|
|
1062
|
-
reasoningEffort: { type: 'string' }
|
|
1194
|
+
reasoningEffort: { type: 'string' },
|
|
1195
|
+
ignored: { type: 'array', items: { type: 'string' } }
|
|
1063
1196
|
}
|
|
1064
1197
|
},
|
|
1065
1198
|
{
|
|
@@ -1071,19 +1204,26 @@ export async function apply(ctx) {
|
|
|
1071
1204
|
preset: { type: 'string' },
|
|
1072
1205
|
provider: { type: 'string' },
|
|
1073
1206
|
model: { type: 'string' },
|
|
1074
|
-
reasoningEffort: { type: 'string' }
|
|
1207
|
+
reasoningEffort: { type: 'string' },
|
|
1208
|
+
ignored: { type: 'array', items: { type: 'string' } }
|
|
1075
1209
|
}
|
|
1076
1210
|
}
|
|
1077
1211
|
]
|
|
1078
1212
|
},
|
|
1079
|
-
render: (_args, value) =>
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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}`
|
|
1083
1222
|
: 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
|
-
|
|
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
|
+
}
|
|
1087
1227
|
},
|
|
1088
1228
|
isConcurrencySafe: () => true,
|
|
1089
1229
|
async execute(args, exec) {
|
|
@@ -1107,8 +1247,9 @@ export async function apply(ctx) {
|
|
|
1107
1247
|
const parentComposed = parentPresets !== undefined ? parentPresets.composedPreset(parent.ctx) : undefined;
|
|
1108
1248
|
if (merged.preset === parentComposed) merged.preset = 'inherit';
|
|
1109
1249
|
}
|
|
1110
|
-
// F5: cost guard (runtime-derived;
|
|
1111
|
-
|
|
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);
|
|
1112
1253
|
// D: effective delegation values for observability.
|
|
1113
1254
|
const meta = {
|
|
1114
1255
|
profile: args.profile ?? '(inline)',
|
|
@@ -1127,6 +1268,13 @@ export async function apply(ctx) {
|
|
|
1127
1268
|
...(merged.toolFilter !== undefined ? { toolFilter: merged.toolFilter } : {}),
|
|
1128
1269
|
...(merged.maxDepth !== undefined ? { maxDepth: merged.maxDepth } : {})
|
|
1129
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'));
|
|
1130
1278
|
// Decision-level log: resolved effective delegation inputs, after the
|
|
1131
1279
|
// cost guard and after request assembly, before dispatch.
|
|
1132
1280
|
ctx.logger.info('[dsh-subagent-profile] dispatch:', JSON.stringify({
|
|
@@ -1144,6 +1292,13 @@ export async function apply(ctx) {
|
|
|
1144
1292
|
// later turns. Treated first so a caller asking for both background and
|
|
1145
1293
|
// continuable gets the continuable child.
|
|
1146
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
|
+
}
|
|
1147
1302
|
if (args.run_in_background === true) {
|
|
1148
1303
|
ctx.logger.warn('[dsh-subagent-profile] dispatch: both continuable and run_in_background are true; continuable takes precedence');
|
|
1149
1304
|
}
|
|
@@ -1154,28 +1309,55 @@ export async function apply(ctx) {
|
|
|
1154
1309
|
if (merged.reasoningEffort !== undefined) {
|
|
1155
1310
|
ctx.logger.warn(`[dsh-subagent-profile] continuable mode cannot set reasoningEffort; ignoring "${merged.reasoningEffort}"`);
|
|
1156
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);
|
|
1157
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
|
+
};
|
|
1158
1339
|
const { childId } = await ctx.subagents.startContinuable({
|
|
1159
1340
|
provider: 'profile',
|
|
1160
1341
|
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
|
-
},
|
|
1342
|
+
request: continuableRequest,
|
|
1173
1343
|
signal: exec.signal
|
|
1174
1344
|
});
|
|
1175
1345
|
// Continuable drops the profile's preset swap and reasoningEffort (the
|
|
1176
1346
|
// child inherits the parent preset), so the observability meta must
|
|
1177
1347
|
// report what actually took effect, not the requested-but-ignored values.
|
|
1178
|
-
|
|
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
|
+
};
|
|
1179
1361
|
}
|
|
1180
1362
|
// A: background one-shot (job) path — jobs.start wraps start() with a
|
|
1181
1363
|
// native AbortController (a Node global in a bundle; the dynamic-plugin
|
|
@@ -1194,7 +1376,7 @@ export async function apply(ctx) {
|
|
|
1194
1376
|
const controller = new AbortController();
|
|
1195
1377
|
return {
|
|
1196
1378
|
cancel: (reason) => controller.abort(reason ?? 'dispatch: background subagent task killed'),
|
|
1197
|
-
done: settleStart(ctx.subagents.start('profile', { ...request, signal: controller.signal }), controller.signal, meta)
|
|
1379
|
+
done: settleStart(ctx.subagents.start('profile', { ...request, signal: controller.signal }), controller.signal, meta, pruneResultOutput)
|
|
1198
1380
|
};
|
|
1199
1381
|
}
|
|
1200
1382
|
});
|
|
@@ -1213,9 +1395,14 @@ export async function apply(ctx) {
|
|
|
1213
1395
|
// partial output text (withPartialText style).
|
|
1214
1396
|
const failure = stopReasonError(result);
|
|
1215
1397
|
if (failure !== undefined) throw new Error(withPartialText(failure, result.output));
|
|
1216
|
-
return { output: textFrom(result.output), ...meta };
|
|
1398
|
+
return { output: textFrom(pruneResultOutput(result.output)), ...meta };
|
|
1217
1399
|
}
|
|
1218
1400
|
});
|
|
1401
|
+
// R1(共享规则)lock: the closed oneOf result schema must carry an identical
|
|
1402
|
+
// shared meta key set across all three branches. Fires only at apply time; a
|
|
1403
|
+
// future meta-field add that forgets one 分支 throws here (once), so the
|
|
1404
|
+
// model-side schema never silently rejects a分支.
|
|
1405
|
+
assertResultSchemaConsistency(dispatchTool.output.schema);
|
|
1219
1406
|
// Register the tool only while enabled; unregister it the moment the switch
|
|
1220
1407
|
// turns off so it disappears from the model's tool list without a restart.
|
|
1221
1408
|
let disposeTool;
|