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/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // index.mjs — dsh-subagent-profile host half (formal plugin bundle).
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
- finalAssistantOutput,
19
+ createUserMessage,
20
+ defineTool,
21
+ readResult,
21
22
  resolveChildAgentOptions,
22
23
  resolveChildDepth,
23
- } from '@deepseek-ai/dsh-subagent';
24
- import { createUserMessage } from '@deepseek-ai/dsh-llm';
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
- 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
- }
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
- // 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
- }
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
- // 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)})`;
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
- // 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;
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
- async function settleStart(start, signal, meta) {
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
- if (!Array.isArray(parsed)) return;
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
- 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);
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(entry.id);
501
+ const existing = profiles.get(clean.id);
479
502
  if (existing !== undefined && existing.builtin === true) {
480
- profiles.set(entry.id, { ...entry, builtin: true, persisted: true });
503
+ profiles.set(clean.id, { ...clean, builtin: true, persisted: true });
481
504
  } else {
482
- profiles.set(entry.id, { ...entry, persisted: true });
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
- /** Persist every `persisted: true` profile plus builtin-delete tombstones — fail-soft. */
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
- 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 });
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
- writeFileSync(profilesFile, JSON.stringify(entries, null, 2), 'utf8');
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
- const existing = profiles.get(profile.id);
681
- const seed = BUILTIN_SEEDS.find((s) => s.id === profile.id);
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
- const clean = { ...(existing ?? seed ?? {}) };
687
- clean.id = profile.id;
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 (profile[key] === undefined) continue; // 未传:保留 existing 原值
690
- if (profile[key] === '' || profile[key] === null) { delete clean[key]; continue; } // 空:清除字段
691
- clean[key] = profile[key];
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
- 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;
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 (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 });
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
- json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
812
+ // 通用 500 不回显内部错误信息(防泄漏),详情只进宿主日志。
813
+ ctx.logger.error('[dsh-subagent-profile] settings route error:', error instanceof Error ? (error.stack ?? error.message) : String(error));
814
+ json(res, 500, { ok: false, error: '内部错误,详情见宿主日志' });
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 (!enabled) return '';
899
+ text: (context) => {
900
+ if (!sectionGatePasses(context)) return '';
800
901
  const rows = [...profiles.values()]
801
902
  .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')}`;
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: '本机已安装 dsh-subagent-profile 插件的「编排者模式」agent preset:新建会话的预设选择器中可选「编排者模式」。该模式把 Agent 定位为主协调者——拆解任务后按场景用 dispatch(内置 swap-standard=标准编码、researcher=调研检索,可在「子 Agent 方案」设置页自定义)与 subagent/subagent_fork/workflow 委派给子 Agent,再整合结果。preset 文件由插件维护于 ~/.dsh/.agent-presets,安装/升级时自动同步;用户提到「编排者模式 / orchestrator / 主协调模式」时即指本预设,请据此协作。'
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; skipped when llm is absent).
840
- await assertCostGuard(parent, profile);
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({ name: 'deployment:persona', order: 0, text: profile.persona });
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
- 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}`
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; skipped when llm is absent).
1111
- await assertCostGuard(parent, merged);
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
- return { kind: 'continuable', subagentId: childId, profile: meta.profile, preset: 'inherit', provider: meta.provider, model: meta.model };
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;