dsh-plugin-tool-management 0.20.2 → 0.21.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +60 -1
  2. package/README.md +4 -1
  3. package/README_EN.md +4 -1
  4. package/docs/update.md +3 -1
  5. package/lib/client.js +225 -54
  6. package/lib/compat/capability-specs.js +60 -1
  7. package/lib/compat/host-contracts.js +72 -0
  8. package/lib/compat/host-probe.js +59 -0
  9. package/lib/compat/host-roots.js +1 -1
  10. package/lib/compat/probe.js +84 -13
  11. package/lib/context-inject-frame.js +134 -26
  12. package/lib/context-inject.js +12 -9
  13. package/lib/http-api.js +3 -2
  14. package/lib/http-fence.js +26 -1
  15. package/lib/index.js +5 -2
  16. package/lib/mcp/manager.js +6 -1
  17. package/lib/mcp/state-section.js +75 -34
  18. package/lib/memories/constants.js +39 -7
  19. package/lib/memories/projection.js +18 -18
  20. package/lib/memories/service.js +8 -7
  21. package/lib/memories/snapshot.js +27 -17
  22. package/lib/ops/compat.js +64 -2
  23. package/lib/ops/memory.js +2 -2
  24. package/lib/ops/prompts.js +30 -2
  25. package/lib/request-gate.js +5 -4
  26. package/lib/skills/catalog.js +2 -2
  27. package/lib/subagents/catalog.js +4 -4
  28. package/lib/subagents/child-preset.js +120 -0
  29. package/lib/subagents/persona-parse.js +9 -9
  30. package/lib/subagents/run-provider.js +218 -62
  31. package/lib/subagents/tool-filter.js +12 -12
  32. package/lib/subagents/tools.js +29 -22
  33. package/lib/tools/deps.js +4 -4
  34. package/lib/tools/mcp.js +34 -34
  35. package/lib/tools/memory.js +34 -34
  36. package/lib/tools/prompt.js +5 -5
  37. package/lib/tools/remove.js +17 -17
  38. package/lib/tools/scene.js +66 -66
  39. package/lib/tools/skills.js +21 -21
  40. package/lib/tools/subagent.js +13 -13
  41. package/lib/tools/table.js +2 -2
  42. package/package.json +25 -24
@@ -0,0 +1,120 @@
1
+ // Agent Preset helpers for persona-scoped child composition.
2
+ //
3
+ // DSH 0.2.1 removed persona/toolFilter from SubagentStartRequest. A spawn child
4
+ // can still select an independent preset, so this module clones the parent's
5
+ // declared preset, replaces its persona row, and registers a short-lived preset
6
+ // id for that child. The clone is mounted by the official registry; nothing in
7
+ // the official package is patched.
8
+ import { createHash } from 'node:crypto';
9
+ import { JSON_SCHEMA, Type, load } from 'js-yaml';
10
+ import { renderPersonaPrompt } from './persona-parse.js';
11
+ const CHILD_PRESET_PREFIX = 'tool-management-child-';
12
+ const JS_EXPR_TAG = 'tag:yaml.org,2002:js';
13
+ const jsExprType = new Type(JS_EXPR_TAG, {
14
+ kind: 'scalar',
15
+ resolve: (data) => typeof data === 'string',
16
+ construct: (data) => ({ __jsExpr: data }),
17
+ represent: (data) => data.__jsExpr,
18
+ });
19
+ const entryListSchema = JSON_SCHEMA.extend(jsExprType);
20
+ /** `!!js` 标量(官方 `entryListSchema` 的 `JsExpr`):克隆时只带 `__jsExpr` 一个键。 */
21
+ function isJsExpr(value) {
22
+ return typeof value === 'object' && value !== null && '__jsExpr' in value;
23
+ }
24
+ function presetRosterOf(ctx) {
25
+ try {
26
+ return ctx?.get?.('agentPresets');
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ function cloneEntry(value) {
33
+ if (isJsExpr(value))
34
+ return { __jsExpr: value.__jsExpr };
35
+ if (!value || typeof value !== 'object')
36
+ return value;
37
+ if (Array.isArray(value))
38
+ return value.map(cloneEntry);
39
+ const copy = {};
40
+ for (const [key, item] of Object.entries(value))
41
+ copy[key] = cloneEntry(item);
42
+ return copy;
43
+ }
44
+ function isPersonaOrPresetSwitch(row) {
45
+ if (row.name === '@deepseek-ai/dsh-persona')
46
+ return true;
47
+ // A custom preset row would replace the whole composition; cloning it would
48
+ // silently change the parent instead of merely overriding its persona.
49
+ return row.name === '@deepseek-ai/dsh-agent-preset';
50
+ }
51
+ function removePersonaRows(rows) {
52
+ const kept = [];
53
+ for (const value of rows) {
54
+ // 不是对象的行:官方 entry list 不接受它,说明这份组合已不是本插件读懂的形状。
55
+ // 静默原样保留会把"人设"语义悄悄换掉(克隆出来的预设带着看不懂的行),所以明确报错。
56
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
57
+ throw new Error('the parent agent preset composition contains a non-object plugin row; cannot clone it safely');
58
+ }
59
+ const row = value;
60
+ if (isPersonaOrPresetSwitch(row))
61
+ continue;
62
+ if (row.group === true) {
63
+ const config = Array.isArray(row.config) ? removePersonaRows(row.config) : row.config;
64
+ kept.push(cloneEntry({ ...row, config }));
65
+ }
66
+ else {
67
+ kept.push(cloneEntry(row));
68
+ }
69
+ }
70
+ return kept;
71
+ }
72
+ /** Read the parent preset's declared rows and return a child definition. */
73
+ export async function childPresetDefinition(parentAgent, persona) {
74
+ const parentCtx = parentAgent?.ctx;
75
+ const roster = presetRosterOf(parentCtx);
76
+ if (!roster || typeof roster.composedPreset !== 'function' || typeof roster.readDocument !== 'function' || typeof roster.register !== 'function') {
77
+ throw new Error('this host lacks the agent-preset read/register API, so no standalone persona can be created for the subagent; upgrade to DSH >= 0.2.1 and enable the agentPresets service');
78
+ }
79
+ const parentPresetId = String(roster.composedPreset(parentCtx) ?? '');
80
+ if (!parentPresetId)
81
+ throw new Error('this parent session has no agent preset bound; cannot safely create a subagent with a persona');
82
+ const document = await roster.readDocument(parentPresetId);
83
+ if (!document || typeof document.content !== 'string') {
84
+ throw new Error(`failed to read the parent agent preset "${parentPresetId}": readDocument returned no composition YAML`);
85
+ }
86
+ let parsed;
87
+ try {
88
+ parsed = load(document.content, { schema: entryListSchema });
89
+ }
90
+ catch (error) {
91
+ throw new Error(`failed to parse the parent agent preset "${parentPresetId}": ${String(error?.message ?? error)}`);
92
+ }
93
+ if (!Array.isArray(parsed)) {
94
+ throw new Error(`the composition of the parent agent preset "${parentPresetId}" is not a list of plugin rows; cannot clone it`);
95
+ }
96
+ const plugins = removePersonaRows(parsed);
97
+ plugins.unshift({
98
+ id: 'tool-management-child-persona',
99
+ name: '@deepseek-ai/dsh-persona',
100
+ config: { prefix: renderPersonaPrompt(persona) },
101
+ });
102
+ // hash 必须覆盖**父预设的组合正文**:同一个 id 的内容可以被 profile 补丁改写,只按
103
+ // id + 人设定哈希会让改过的内容继续复用旧定义(冷恢复/追问时加载的是上一版插件树)。
104
+ const hash = createHash('sha256')
105
+ .update(parentPresetId)
106
+ .update('\0')
107
+ .update(document.content)
108
+ .update('\0')
109
+ .update(persona.name)
110
+ .update('\0')
111
+ .update(renderPersonaPrompt(persona))
112
+ .digest('hex')
113
+ .slice(0, 16);
114
+ return {
115
+ id: CHILD_PRESET_PREFIX + hash,
116
+ name: `persona:${persona.name}`,
117
+ description: `Ephemeral child preset cloned from ${parentPresetId}`,
118
+ plugins: plugins,
119
+ };
120
+ }
@@ -238,12 +238,12 @@ export const DEFAULT_PERSONA_CATALOG_DEPTH = 1;
238
238
  * 放在本模块(叶子)而不是 `catalog.ts`:`tools.ts` 也要用它,而 `catalog.ts` 反向依赖
239
239
  * `tools.ts` 的 `filterBySceneBinding` —— 放那边会形成 tools ↔ catalog 的循环。
240
240
  */
241
- export const CONTINUABLE_MARK = '(允许追问)';
241
+ export const CONTINUABLE_MARK = ' (continuable)';
242
242
  /**
243
243
  * 「不限制嵌套」的目录注入深度(界面下拉的第四档,前端同值见 client.js 的
244
244
  * `CATALOG_DEPTH_UNLIMITED`)。
245
245
  *
246
- * 取值远大于宿主能嵌套到的深度(官方 `dsh-tool-subagent` 默认 `maxDepth: 3`),所以判据
246
+ * 取值远大于宿主能嵌套到的深度(0.2.1 的 `dsh-subagent` 默认 `maxDepth: 1`),所以判据
247
247
  * `深度 < catalogDepth` 在任何可达的会话里都成立 —— 判据本身不必为它加特例分支。
248
248
  */
249
249
  export const UNLIMITED_PERSONA_CATALOG_DEPTH = 99;
@@ -330,18 +330,18 @@ export function textOfBlocks(blocks, type) {
330
330
  export function emptyResultNote(output, stopReason) {
331
331
  // 连思考都没有 → 子代理确实什么都没产出,这时说「没干活」是对的。
332
332
  if (textOfBlocks(output, 'reasoning') === '')
333
- return '(子代理无输出)';
334
- const head = '子代理没有返回正文 —— 它产出了思考';
333
+ return '(the subagent produced no output)';
334
+ const head = 'the subagent returned no text — only reasoning';
335
335
  switch (stopReason) {
336
336
  case 'max-tokens':
337
- return `(${head},但写出结论前就用完了 token 上限;需要结果请把任务拆小些再委派。)`;
337
+ return `(${head}, but it hit the token limit before writing a conclusion; split the task smaller and delegate again if you need a result.)`;
338
338
  case 'error':
339
- return `(${head},但中途出错了;诊断信息见后。)`;
339
+ return `(${head}, but it errored; diagnostics follow.)`;
340
340
  case 'aborted':
341
- return `(${head},但写出结论前被中止了。)`;
341
+ return `(${head}, but it was aborted before writing a conclusion.)`;
342
342
  case 'refusal':
343
- return `(${head},但它拒绝了这项任务。)`;
343
+ return `(${head}, but it refused this task.)`;
344
344
  default:
345
- return `(${head},但没有写出结论;可以拆小任务重新委派,或你自己接着做。)`;
345
+ return `(${head}, and wrote no conclusion; split the task and delegate again, or carry on yourself.)`;
346
346
  }
347
347
  }
@@ -8,7 +8,9 @@
8
8
  // 就等于换了解析基准。传引用才谈得上逐字不变。
9
9
  // 运行侧不碰人设清单的那份缓存(`cache` / `enabledCache` 都在 service.ts 那一层),
10
10
  // 所以它只需要 ctx / req / message 三件。
11
+ import { randomUUID } from 'node:crypto';
11
12
  import { emptyResultNote, renderPersonaPrompt, RESULT_MAX, textOfBlocks, } from './persona-parse.js';
13
+ import { childPresetDefinition } from './child-preset.js';
12
14
  // 拆出四层后 subagents/service.js 的公开面照旧(index.ts、ops/sessions.ts、scenes/candidates.ts、
13
15
  // subagents/tools.ts 与契约测试都按这个路径引)。
14
16
  export { catalogInjectedAt, catalogDepthOf, DEFAULT_PERSONA_CATALOG_DEPTH, emptyResultNote, neutralizePromptVariables, normalizeReasoningEffort, parsePersona, parsePresetToolRules, renderPersonaPrompt, REASONING_EFFORT_MAX_LENGTH, textOfBlocks, UNLIMITED_PERSONA_CATALOG_DEPTH, } from './persona-parse.js';
@@ -16,8 +18,8 @@ export { presetRulesOf, serializePersona, validPersonaName } from './persona-wri
16
18
  export { decideToolFilter } from './tool-filter.js';
17
19
  export function createRunProvider(deps) {
18
20
  const PROVIDER_PACKAGES = {
19
- spawn: { pkg: '@deepseek-ai/dsh-subagent-spawn-in-process', label: 'spawn(新会话)' },
20
- fork: { pkg: '@deepseek-ai/dsh-subagent-fork-in-process', label: 'fork(继承本次会话)' },
21
+ spawn: { pkg: '@deepseek-ai/dsh-subagent-spawn-in-process', label: 'spawn (fresh session)' },
22
+ fork: { pkg: '@deepseek-ai/dsh-subagent-fork-in-process', label: 'fork (inherits this session)' },
21
23
  };
22
24
  const providerMounts = {};
23
25
  function hasProvider(runtime, kind) {
@@ -33,8 +35,8 @@ export function createRunProvider(deps) {
33
35
  }
34
36
  async function ensureProvider(kind) {
35
37
  const runtime = deps.ctx.get?.('subagents');
36
- if (!runtime || typeof runtime.start !== 'function') {
37
- throw new Error('子代理服务未挂载(ctx.subagents 缺失;请确认 DSH 版本 ≥0.1.5-rc.2)');
38
+ if (!runtime || typeof runtime.startActivation !== 'function') {
39
+ throw new Error('the subagent service is not mounted or lacks startActivation (ctx.subagents is missing; check that DSH is >= 0.2.1-rc.2)');
38
40
  }
39
41
  if (hasProvider(runtime, kind))
40
42
  return runtime;
@@ -46,31 +48,132 @@ export function createRunProvider(deps) {
46
48
  try {
47
49
  const mod = deps.req(pkg);
48
50
  if (!mod || typeof mod.apply !== 'function')
49
- throw new Error('模块未导出 apply(ctx, config)(版本不匹配?)');
51
+ throw new Error('the module does not export apply(ctx, config) (version mismatch?)');
50
52
  mod.apply(deps.ctx, { providerName: kind });
51
53
  providerMounts[kind] = { ok: true };
52
54
  }
53
55
  catch (e) {
54
56
  providerMounts[kind] = {
55
57
  ok: false,
56
- error: `${label} provider 不可用:宿主没有注册它,本插件挂载 ${pkg} 也失败(`
57
- + deps.message(e) + ')。请在宿主 profile 挂载该包(本插件已声明为可选 peerDependency),或安装后重启 DSH。',
58
+ error: `the ${label} provider is unavailable: the host did not register it and mounting ${pkg} from this plugin failed too (`
59
+ + deps.message(e) + '). Mount that package in the host profile (this plugin declares it as an optional peerDependency), or install it and restart DSH.',
58
60
  };
59
61
  }
60
62
  }
61
63
  const mount = providerMounts[kind];
62
64
  if (!mount || !mount.ok)
63
- throw new Error(mount ? mount.error : 'provider 挂载失败(未知原因)');
65
+ throw new Error(mount ? mount.error : 'provider mount failed (unknown reason)');
64
66
  if (!hasProvider(runtime, kind))
65
- throw new Error(`${kind} provider 挂载后仍未出现在 ctx.subagents.list()(宿主版本不匹配?)`);
67
+ throw new Error(`the ${kind} provider still does not appear in ctx.subagents.list() after mounting (host version mismatch?)`);
66
68
  return runtime;
67
69
  }
70
+ /**
71
+ * Install the persona's optional tool mask on one exact child scope. The
72
+ * official request no longer accepts toolFilter, so this is the only place
73
+ * the mask can be applied without widening the child's inherited tools.
74
+ *
75
+ * Must run inside `agent/created` for that exact child (0.2.1):the official
76
+ * factory awaits every serial `agent/created` listener **before** it releases
77
+ * the queued first prompt (`dsh-agent/lib/types/index.js:334` → `announce`),
78
+ * so a restriction installed here is already in force at the child's first
79
+ * tool call. Installing after `startActivation()` resolves would race: the
80
+ * first turn could already have run with the inherited tools.
81
+ */
82
+ function restrictChildTools(childAgent, filter) {
83
+ const tools = childAgent?.ctx?.get?.('tools');
84
+ if (!tools || typeof tools.restrict !== 'function') {
85
+ throw new Error('the subagent tool scope lacks tools.restrict(); cannot apply a tool restriction');
86
+ }
87
+ const dispose = tools.restrict(filter);
88
+ return typeof dispose === 'function' ? dispose : () => undefined;
89
+ }
90
+ /**
91
+ * 临时子预设的引用计数表:同一份定义(父预设 + 人设)只注册一次。
92
+ *
93
+ * 三件事逼着它存在(0.2.1):
94
+ * ① 复用 —— 同一个 (父预设, 人设) 反复委派时重复 `register()` 会因官方
95
+ * `Duplicate agent preset` 直接失败;
96
+ * ② 失败路径释放 —— 启动失败还留着定义,会在宿主 registry 里堆一份永久的插件树,
97
+ * 还会出现在 `agentPresets.list()` 的名单里;
98
+ * ③ 追问子代理**不能**在启动后立刻注销 —— 冷恢复按 `agent-preset/selected` 投影找预设
99
+ * (`dsh-subagent/lib/index.js` → `NOT_RESUMABLE`),提前注销会让后续追问失败。
100
+ * 引用归零才真正注销;插件卸载时统一释放(见 installChildPresetCleanup)。
101
+ */
102
+ const presetLeases = new Map();
103
+ /** 已注册的临时预设 → 官方返回的注销函数。 */
104
+ const presetUnregisters = new Map();
105
+ /**
106
+ * 取(必要时注册)一份临时子预设的引用;返回的 release 由调用方在 finally 里调。
107
+ */
108
+ async function acquireChildPreset(definition) {
109
+ const id = definition.id;
110
+ const existing = presetLeases.get(id);
111
+ if (existing !== undefined) {
112
+ existing.refs += 1;
113
+ return async () => {
114
+ const lease = presetLeases.get(id);
115
+ if (lease === undefined || lease !== existing)
116
+ return;
117
+ lease.refs -= 1;
118
+ if (lease.refs > 0)
119
+ return;
120
+ presetLeases.delete(id);
121
+ const unregister = presetUnregisters.get(id);
122
+ if (unregister === undefined)
123
+ return;
124
+ presetUnregisters.delete(id);
125
+ await unregister().catch(() => undefined);
126
+ };
127
+ }
128
+ const roster = deps.ctx.get?.('agentPresets');
129
+ if (!roster || typeof roster.register !== 'function') {
130
+ throw new Error('the agentPresets service is unavailable: cannot register the subagent persona preset');
131
+ }
132
+ let unregister;
133
+ try {
134
+ unregister = await roster.register(definition);
135
+ }
136
+ catch (error) {
137
+ throw new Error(`failed to register the subagent persona preset: ${deps.message(error)}`);
138
+ }
139
+ presetLeases.set(id, { refs: 1 });
140
+ presetUnregisters.set(id, unregister);
141
+ return async () => {
142
+ const lease = presetLeases.get(id);
143
+ if (lease === undefined)
144
+ return;
145
+ lease.refs -= 1;
146
+ if (lease.refs > 0)
147
+ return;
148
+ presetLeases.delete(id);
149
+ presetUnregisters.delete(id);
150
+ // 释放失败只影响清理,绝不让它盖住主路径的错误(调用方一律 catch 掉)。
151
+ await unregister().catch(() => undefined);
152
+ };
153
+ }
154
+ /** 插件卸载(HMR / 停用 / 更新)时释放全部临时预设。 */
155
+ function installChildPresetCleanup() {
156
+ if (typeof deps.ctx?.effect !== 'function')
157
+ return;
158
+ try {
159
+ deps.ctx.effect(() => () => {
160
+ presetLeases.clear();
161
+ const unregisters = [...presetUnregisters.values()];
162
+ presetUnregisters.clear();
163
+ // 串行释放:官方 unregister 会 retire 一代并等它被收集,并发会互相踩代数。
164
+ let chain = Promise.resolve();
165
+ for (const unregister of unregisters) {
166
+ chain = chain.then(() => Promise.resolve(unregister()).catch(() => undefined));
167
+ }
168
+ void chain;
169
+ }, 'dsh-plugin-tool-management: child preset cleanup');
170
+ }
171
+ catch { /* effect 不可用 → 只影响清理,不影响本次委派 */ }
172
+ }
173
+ installChildPresetCleanup();
68
174
  async function runOnce(parentAgent, p, task, signal, toolFilter, inherit) {
69
- // 这里**刻意没有**"预算用尽就拒绝"的前置检查(2026-09-17 用户实测后拆掉)。
70
- // 它此前基于一个错误假设:以为传 `maxDepth: 1` 会让子会话再委派必然失败。实际上官方
71
- // `dsh-tool-subagent` 的默认是 **3**、provider 只在**传了值**时才校验(`resolveChildDepth`),
72
- // 所以子代理本来就能继续嵌套。那个检查唯一的净效果是让**我们的**工具比官方严 ——
73
- // 同一个子会话里官方工具能委派、我们的被自己拦下 —— 而用户真想禁止嵌套也禁止不了。
175
+ // 这里**刻意没有**"预算用尽就拒绝"的前置检查:宿主统一决定委派深度
176
+ // (当前默认 maxDepth=1),本工具不传 maxDepth,也不自行判断能否继续委派。
74
177
  //
75
178
  // 通道选择(方案 C,2026-09-17):`inherit` → 官方 fork provider(子会话被**本次会话已完成的
76
179
  // 对话**播种,`task` 只需写新增部分);默认 → spawn(全新会话,任务必须自包含)。
@@ -78,8 +181,7 @@ export function createRunProvider(deps) {
78
181
  // (session-ee722e23)上下文里明明有「先在这里选人设…用 subagent_manager_run 执行」与
79
182
  // `code-review` 人设,模型在**读进 README 之后**仍选了 `subagent_fork`:那能把已读内容带过去、
80
183
  // 不必复述。人设通道缺这一档时,"贴合人设的任务"会持续被官方工具抢走 —— 补齐能力比改文案
81
- // 更根本(模型选的是省力,不是没看见)。fork provider 的能力位(persona / toolFilter /
82
- // agentOptions)与 spawn 相同,所以请求体不用分叉。
184
+ // 更根本(模型选的是省力,不是没看见)。
83
185
  const kind = inherit === true ? 'fork' : 'spawn';
84
186
  const runtime = await ensureProvider(kind);
85
187
  // 工具限制三态:对象 = 调用方已按当前预设决定;null = 调用方明确决定不加限制;
@@ -109,58 +211,107 @@ export function createRunProvider(deps) {
109
211
  },
110
212
  }
111
213
  : {};
112
- // ── 允许追问通道(人设 `continuable: true`,0.19.0)────────────────────────────
113
- // 与一次性走的是**两条官方通道**:`startContinuable` 建持久子会话,在**收件箱受理时**
114
- // 即兑现(不是跑完),返回稳定 `childId`,子代理在后台继续跑。追问不需要本插件再出工具
115
- // —— 宿主自带全局 `send_message` / `interrupt_agent`(`dsh-tool-subagent-control`),
116
- // 它们对任何可继续子级都生效,与创建它的工具无关。这里只负责"把它建起来"。
214
+ // 0.2.1 起 persona/toolFilter 不再属于 SubagentStartRequest。fork 明确不支持独立
215
+ // Agent Preset,因此带工具限制的 fork 必须在启动前报错,不能静默放宽权限。
216
+ if (kind === 'fork' && resolved !== null) {
217
+ throw new Error('fork does not support its own tool restriction: remove the tools/toolsDeny/toolsByPreset restriction this persona carries in this preset, '
218
+ + 'then use inherit=true; if the tool restriction is required, switch back to the default spawn (inherit=false).');
219
+ }
220
+ let releasePreset;
221
+ let agentPreset;
222
+ if (kind === 'spawn') {
223
+ const definition = await childPresetDefinition(parentAgent, p);
224
+ releasePreset = await acquireChildPreset(definition);
225
+ agentPreset = definition.id;
226
+ }
227
+ const childFilter = kind === 'spawn' ? resolved : null;
228
+ // 带工具限制的 spawn:`childId` 由我们预留,并在父 scope 的 `agent/created` 上按
229
+ // **精确 id** 施加限制。
117
230
  //
118
- // 两条官方语义(`dsh-subagent/lib/index.js:599-654`)决定了回执只能写成"已启动 + id":
119
- // · 子代理启动时被追加一句返回指引,要求它**自己**用 send_message 把结果发给父代理
120
- // —— 父代理不会自动收到它的转录、工具输出或思考;
121
- // · 结算时父代理还会收到一条 `subagent-settled` 通知(含收尾正文)。
122
- // 所以这条通道的产出是**异步**到达的,不是本函数的返回值。
123
- if (p.continuable === true) {
124
- const started = await runtime.startContinuable({
231
+ // 为什么必须这么做(0.2.1 的启动顺序):官方本地激活的首条 prompt 在 `startLocal()`
232
+ // 里就入 inbox(`dsh-subagent/lib/index.js` 的 `submitMaterialized`),而 announce 是
233
+ // **串行 `agent/created` 监听全部完成后才放行收件箱**(`dsh-agent/lib/types/index.js`
234
+ // 的 `announce` → `await this.ctx.serial(entry.carrier, 'agent/created', …)`)。
235
+ // 所以在这里施加 = 首轮工具调用之前已生效;等 `startActivation()` 返回再
236
+ // `restrict()` 就慢了一步 —— 首轮看到的还是继承来的工具集,人设的工具限制形同虚设。
237
+ //
238
+ // 监听器里取不到 child 或没有 `restrict()` 时**明确抛错**:监听器抛出会让官方把这次
239
+ // 创建判失败并回滚(fail-closed),绝不静默放宽。
240
+ // `childFilter` 只在 !== null 时进入下面的分支,但 TS 的收窄在这里失效(闭包捕获 +
241
+ // 外层 `const childId` 的推导),所以显式把非空那份收窄出来。
242
+ const childFilterValue = childFilter;
243
+ const childId = childFilterValue === null ? undefined : 'tool-management-child-' + randomUUID();
244
+ let stopWatching;
245
+ let restrictionFailure;
246
+ if (childId !== undefined) {
247
+ const parentCtx = parentAgent?.ctx;
248
+ if (typeof parentCtx?.on !== 'function') {
249
+ await releasePreset?.().catch(() => undefined);
250
+ throw new Error('the parent agent scope is unavailable: cannot apply the tool restriction before the subagent is created');
251
+ }
252
+ const onCreated = (payload) => {
253
+ const agent = payload && payload.agent;
254
+ if (!agent || String(agent.id) !== String(childId))
255
+ return;
256
+ try {
257
+ restrictChildTools(agent, childFilterValue);
258
+ }
259
+ catch (error) {
260
+ restrictionFailure = error instanceof Error ? error : new Error(String(error));
261
+ throw restrictionFailure;
262
+ }
263
+ };
264
+ // `ctx.on` 没返回 disposer(老宿主 / 异常)→ 用恒空函数兜底:监听会随父 scope
265
+ // 生命周期结束,调用侧只需保证自己调 `stopWatching()` 时不再抛。
266
+ const stop = parentCtx.on('agent/created', onCreated);
267
+ stopWatching = typeof stop === 'function' ? stop : () => undefined;
268
+ }
269
+ const startSignal = signal ?? new AbortController().signal;
270
+ let activation;
271
+ try {
272
+ activation = await runtime.startActivation({
125
273
  provider: kind,
126
274
  label: p.name,
127
- // `ContinuableStartSpec.request` 是 `Omit<SubagentStartRequest,'label'|'signal'|'outputSchema'>`:
128
- // label 提到 spec 顶层、signal 由 spec 自己带(官方接管准备期的取消),两者都不进 request。
275
+ ...(childId === undefined ? {} : { childId }),
129
276
  request: {
130
277
  parent: parentAgent,
131
- prompt: [{ type: 'text', text: task }],
132
- persona: renderPersonaPrompt(p),
133
- ...(resolved === null ? {} : { toolFilter: resolved }),
278
+ prompt: [{ type: 'text', text: kind === 'fork' ? renderPersonaPrompt(p) + '\n\n' + task : task }],
279
+ ...(agentPreset === undefined ? {} : { agentPreset }),
134
280
  ...routeOptions,
135
281
  },
136
- signal: signal ?? new AbortController().signal,
282
+ signal: startSignal,
283
+ delivery: p.continuable === true ? 'parent' : 'caller',
137
284
  });
138
- return { text: '', runId: String(started?.childId ?? ''), stopReason: 'started', continuable: true };
139
- }
140
- // 官方签名:start(name, request) —— name = ctx.subagents 上的 provider 注册名。
141
- const run = await runtime.start(kind, {
142
- label: p.name,
143
- parent: parentAgent,
144
- // 官方 SubagentStartRequest.signal 为必填:调用方缺省时给一个永不中止的信号,不传 undefined。
145
- signal: signal ?? new AbortController().signal,
146
- prompt: [{ type: 'text', text: task }],
147
- // 人设不是裸正文:宿主的 persona 槽位(section order 0)原本是部署级短前缀,
148
- // 框架该由填槽方承担。见 renderPersonaPrompt 的说明 —— 裸拼的后果是模型把它
149
- // 读成身份句的续写,而不是一个被指定的角色。
150
- persona: renderPersonaPrompt(p),
151
- // 工具白/黑名单 → 官方 ToolRestriction(`deny` 优先级高于 `allow`;未知名官方会直接拒绝启动,
152
- // 所以名单由 index.ts 按当前预设 + 当前真实存在的工具名算好再传进来,见 decideToolFilter)。
153
- ...(resolved === null ? {} : { toolFilter: resolved }),
154
- // **刻意不传 `maxDepth`**(2026-09-17 用户裁定,方案 A):它是官方的**真·递归上限**
155
- // (`resolveChildDepth` 会抛 `SubagentDepthError`),而我们的 `catalogDepth` 只想决定
156
- // "目录出现在哪"。传了它会让我们的工具比官方严(1 vs 3),且会要求 provider 具备
157
- // `depthLimit` capability(不传就没这个依赖)。让 provider 用它自己的默认,与官方
158
- // 工具的能力保持一致。
159
- // provider / model / reasoningEffort 的合并语义见上方 `routeOptions` 的注释。
160
- ...routeOptions,
161
- });
285
+ }
286
+ catch (error) {
287
+ try {
288
+ stopWatching?.();
289
+ }
290
+ catch { /* ignore */ }
291
+ await releasePreset?.().catch(() => undefined);
292
+ // 失败可能就是我们自己的监听器抛的(fail-closed 那条):那句话更接近根因,
293
+ // 别让官方的通用报错把它盖掉。
294
+ if (restrictionFailure === undefined)
295
+ throw error;
296
+ throw new Error(`failed to apply the subagent tool restriction: ${restrictionFailure.message}`, { cause: error });
297
+ }
298
+ // 创建窗口结束,监听器摘掉(它只服务这一个 child id)。
299
+ try {
300
+ stopWatching?.();
301
+ }
302
+ catch { /* ignore */ }
303
+ if (restrictionFailure !== undefined) {
304
+ // 监听器抛了但激活仍然建立(理论竞态):撤掉这次激活,绝不让一个限制没生效的
305
+ // 子代理继续跑。
306
+ await activation.dispose().catch(() => undefined);
307
+ await releasePreset?.().catch(() => undefined);
308
+ throw new Error(`failed to apply the subagent tool restriction: ${restrictionFailure.message}`);
309
+ }
310
+ if (p.continuable === true) {
311
+ return { text: '', runId: String(activation.childId ?? ''), stopReason: 'started', continuable: true };
312
+ }
162
313
  try {
163
- const result = await run.result; // 官方契约:child 级失败不 reject(stopReason 体现)
314
+ const result = await activation.result; // 官方契约:child 级失败不 reject(stopReason 体现)
164
315
  // 只取 `text` 块(见 textOfBlocks)。此前这里只判了 `typeof b.text === 'string'`,
165
316
  // 于是 `reasoning` 块被当成正文 —— 子代理返回的正文里混着它的思考(计数、自我校验),
166
317
  // 只有思考时整段返回思考。本机 298 条终局消息里 98.7% 命中。
@@ -170,18 +321,23 @@ export function createRunProvider(deps) {
170
321
  const stopReason = String(result.stopReason ?? 'completed');
171
322
  return {
172
323
  text: ((text || emptyResultNote(result.output, stopReason)) + diagnostic).slice(0, RESULT_MAX),
173
- runId: String(run.id ?? ''),
324
+ runId: String(activation.childId ?? ''),
174
325
  stopReason,
175
326
  };
176
327
  }
177
328
  finally {
178
- await run.dispose().catch(() => { }); // 官方前台语义:collection 后即弃
329
+ await activation.dispose().catch(() => { }); // 官方前台语义:collection 后即弃
330
+ // 一次性子代理走完即弃:这一轮没人再需要这份预设定义。释放失败只影响清理,
331
+ // 所以 catch 掉 —— 它绝不能盖住上面已经算出 / 抛出的结果。
332
+ await releasePreset?.().catch(() => undefined);
179
333
  }
180
334
  }
181
335
  // v1 串行:同一时刻至多一次**启动**在途(设计 §3.2)。
182
336
  // ⚠️ 0.19.0 起这条不再等于"至多一个子代理在跑":允许追问(`continuable`)的子代理在
183
- // `startContinuable` 返回后仍在后台运行,而这里只串行化"启动"这一步。这是官方通道的
337
+ // `startActivation()` 返回后仍在后台运行,而这里只串行化"启动"这一步。这是官方通道的
184
338
  // 固有语义(后台子级本来就允许并存),不是本插件放宽了限制。
339
+ // `continuable: true` 的返回值是 `{ text: '', stopReason: 'started' }` —— 产出不在这里,
340
+ // 而由官方的结算通知在子代理结束时送到父代理(见 persona-types.ts 的 continuable 注释)。
185
341
  let chain = Promise.resolve();
186
342
  const runSerial = (parentAgent, p, task, signal, toolFilter, inherit) => {
187
343
  const start = () => runOnce(parentAgent, p, task, signal, toolFilter, inherit);
@@ -52,15 +52,15 @@ export function decideToolFilter(persona, presetId, known) {
52
52
  const dropped = rule.names.filter((name) => !known.names.has(name));
53
53
  const notes = [];
54
54
  if (dropped.length)
55
- notes.push(`名单里这些工具当前不存在,已忽略:${dropped.join('、')}`);
55
+ notes.push(`these tools in the list do not exist right now and were ignored: ${dropped.join(', ')}`);
56
56
  if (reserved.dropped.length) {
57
- notes.push(`名单里的 ${reserved.dropped.join('、')} 是官方保留名(写进工具限制会让子代理直接起不来),已忽略`);
57
+ notes.push(`${reserved.dropped.join(', ')} in the list are reserved official names (writing them into the tool restriction would stop the subagent from starting at all) and were ignored`);
58
58
  }
59
59
  if (!usable.length) {
60
- const note = notes.length ? notes.join(';') : undefined;
61
- return { filter: null, note: note ?? `「${presetId}」的${rule.mode === 'allow' ? '白' : '黑'}名单里没有当前存在的工具,本次不施加工具限制` };
60
+ const note = notes.length ? notes.join('; ') : undefined;
61
+ return { filter: null, note: note ?? `the ${rule.mode === 'allow' ? 'allow' : 'deny'} list of "${presetId}" contains no tool that exists right now, so no tool restriction is applied this time` };
62
62
  }
63
- const droppedNote = notes.length ? notes.join(';') : undefined;
63
+ const droppedNote = notes.length ? notes.join('; ') : undefined;
64
64
  if (rule.mode === 'allow')
65
65
  return { filter: { allow: [...new Set([...usable, ...known.mcp])] }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
66
66
  return { filter: { deny: usable }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
@@ -81,18 +81,18 @@ export function decideToolFilter(persona, presetId, known) {
81
81
  if (presetId === null) {
82
82
  // 说清这一刻到底靠什么在跑:有旧格式就照旧格式,没有就明说"按模式配的限制这次不生效"。
83
83
  notes.push(hasLegacy
84
- ? '没能判断当前会话的 Agent 预设,按旧格式的全局名单执行'
85
- : '没能判断当前会话的 Agent 预设,按模式配的限制这次不生效');
84
+ ? 'could not determine the agent preset of this session; running the legacy global list instead'
85
+ : 'could not determine the agent preset of this session; the mode-scoped restriction does not apply this time');
86
86
  }
87
87
  if (legacyDropped.length)
88
- notes.push(`名单里这些工具当前不存在,已忽略:${legacyDropped.join('、')}`);
88
+ notes.push(`these tools in the list do not exist right now and were ignored: ${legacyDropped.join(', ')}`);
89
89
  if (legacyReserved.dropped.length) {
90
- notes.push(`名单里的 ${legacyReserved.dropped.join('、')} 是官方保留名(写进工具限制会让子代理直接起不来),已忽略`);
90
+ notes.push(`${legacyReserved.dropped.join(', ')} in the list are reserved official names (writing them into the tool restriction would stop the subagent from starting at all) and were ignored`);
91
91
  }
92
92
  if (!Object.keys(filter).length) {
93
93
  if (presetId === null && !notes.length)
94
- notes.push('没能判断当前会话的 Agent 预设,本次不施加工具限制');
95
- return { filter: null, ...(notes.length ? { note: notes.join(';') } : {}) };
94
+ notes.push('could not determine the agent preset of this session; no tool restriction is applied this time');
95
+ return { filter: null, ...(notes.length ? { note: notes.join('; ') } : {}) };
96
96
  }
97
- return { filter, ...(notes.length ? { note: notes.join(';') } : {}) };
97
+ return { filter, ...(notes.length ? { note: notes.join('; ') } : {}) };
98
98
  }