dsh-plugin-tool-management 0.20.3 → 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 +37 -0
  2. package/README.md +4 -1
  3. package/README_EN.md +4 -1
  4. package/docs/update.md +1 -0
  5. package/lib/client.js +117 -11
  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
@@ -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
  }
@@ -29,14 +29,14 @@ export async function filterBySceneBinding(docs, enabledSceneLists, includeUnava
29
29
  if (includeUnavailable || !bound.size)
30
30
  return { allowed: live, reason: null };
31
31
  const allowed = live.filter((d) => bound.has(d.name));
32
- return { allowed, reason: allowed.length ? null : `当前启用场景的子智能体绑定: ${[...bound].join('、')}` };
32
+ return { allowed, reason: allowed.length ? null : `the enabled scene's subagent bindings: ${[...bound].join(', ')}` };
33
33
  }
34
34
  export function defineSubagentManagerListTool(subagents) {
35
35
  return {
36
36
  name: 'subagent_manager_list',
37
37
  // `depth-filtered` 删掉(2026-10-15 注入文本审查):注入深度是**注入侧**的实现细节,
38
38
  // 模型看到的是"目录里有哪些人设",`catalogDepth` 那个词对它没有任何可操作性。
39
- description: 'List personas (pre-configured subagent profiles) with their descriptions. The「当前子智能体」reminder carries a view of the same catalog; call this for the always-current full list, or pass all=true to include personas that are disabled or outside the active scene.',
39
+ description: 'List personas (pre-configured subagent profiles) with their descriptions. The "Current subagents" reminder carries a view of the same catalog; call this for the always-current full list, or pass all=true to include personas that are disabled or outside the active scene.',
40
40
  parameters: {
41
41
  all: { type: 'boolean', description: 'Include disabled personas and ones outside the active scene (default false). Listed ≠ delegable: `run` still refuses those.' },
42
42
  },
@@ -54,9 +54,9 @@ export function defineSubagentManagerListTool(subagents) {
54
54
  // 而工具又不列全,结果是"明明能委派却查不到人设"。
55
55
  // `all=true` 时**标出哪些不可用**:只列名字的话,模型照样分不清哪个能直接委派
56
56
  // (与技能列表标 `shadowed-by:` 同一条理由 —— 列出来不等于能用)。
57
- const lines = allowed.map((p) => '- ' + p.name + ' — ' + (p.description || '(无描述)') +
57
+ const lines = allowed.map((p) => '- ' + p.name + ' — ' + (p.description || '(no description)') +
58
58
  (p.continuable === true ? CONTINUABLE_MARK : '') +
59
- (includeUnavailable ? (p.enabled === false ? '(已停用,需先启用)' : '') : ''));
59
+ (includeUnavailable ? (p.enabled === false ? ' (disabled — turn it on first)' : '') : ''));
60
60
  // 默认口径下有多少个没列出来(停用的 + 不在启用场景里的):不报的话,模型看到一份短名单
61
61
  // 会当成"本机只有这些人设"。数量照报,与另外三个 list 的表头同一口径。
62
62
  const hiddenCount = includeUnavailable ? 0 : docs.length - allowed.length;
@@ -67,9 +67,9 @@ export function defineSubagentManagerListTool(subagents) {
67
67
  catch {
68
68
  notice = '';
69
69
  }
70
- const header = '人设子智能体(' + allowed.length + ')' +
71
- (hiddenCount > 0 ? '(另有 ' + hiddenCount + ' 个停用或不在当前场景,传 all=true 查看)' : '') + ':\n';
72
- return header + (lines.join('\n') || '(无)') + (reason ? '\n注意:' + reason : '') + notice;
70
+ const header = 'persona subagents (' + allowed.length + ')' +
71
+ (hiddenCount > 0 ? ' (' + hiddenCount + ' more are disabled or outside the current scene; pass all=true to see them)' : '') + ':\n';
72
+ return header + (lines.join('\n') || '(none)') + (reason ? '\nnote: ' + reason : '') + notice;
73
73
  },
74
74
  };
75
75
  }
@@ -105,9 +105,9 @@ export function defineSubagentManagerRunTool(subagents) {
105
105
  // 最贵的单个参数。收完两处重复后目标 ≈325 tok。判据是"删掉的那句在描述里已经有了吗":
106
106
  // Modes 段留着(它是模型的入口),参数说明只留"这一段独有的信息"。
107
107
  // `inherit` 那句"轮中委派拿不到当前轮"必须留 —— 它是这条参数唯一会让人写错 task 的地方。
108
- description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output. A persona marked「允许追问」instead returns an agent id and runs in the background — continue it with `send_message`.\n\nModes: by default a fresh child that cannot see this conversation, so `task` must be self-contained. With `inherit: true` it also gets this conversation\'s **finished** turns (like the host\'s `subagent_fork`) — the current turn is never included, so a mid-turn hand-off still needs a self-contained `task`.\n\n`task` = the goal plus the context it needs; leave method and output format to the persona.\n\nUse it when the work matches a persona in the「当前子智能体」reminder (a review, an investigation, a piece of writing) and the detail should not sit in your own context. Not for reading a file (Read), finding a definition (Grep/Glob), or touching two or three files.',
108
+ description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output. A persona marked `(continuable)` instead returns an agent id and runs in the background — continue it with `send_message`.\n\nModes: by default a fresh child that cannot see this conversation, so `task` must be self-contained. With `inherit: true` it also gets this conversation\'s **finished** turns (like the host\'s `subagent_fork`) — the current turn is never included, so a mid-turn hand-off still needs a self-contained `task`.\n\n`task` = the goal plus the context it needs; leave method and output format to the persona.\n\nUse it when the work matches a persona in the "Current subagents" reminder (a review, an investigation, a piece of writing) and the detail should not sit in your own context. Not for reading a file (Read), finding a definition (Grep/Glob), or touching two or three files.',
109
109
  parameters: {
110
- agent: { type: 'string', required: true, description: 'Persona name — see the「当前子智能体」reminder, or list personas with subagent_manager_list.' },
110
+ agent: { type: 'string', required: true, description: 'Persona name — see the "Current subagents" reminder, or list personas with subagent_manager_list.' },
111
111
  // `task` 的说明只留"这是什么"(2026-10-15 注入文本审查):上面描述里那句
112
112
  // "leave method and output format to the persona." 此前在这里**逐字重复**了一遍
113
113
  // (57 字符),而模型填这个参数时两处都在手上 —— 同一件事付两次 token。
@@ -123,22 +123,23 @@ export function defineSubagentManagerRunTool(subagents) {
123
123
  const task = String((args && args.task) || '').trim();
124
124
  const inherit = args && args.inherit === true;
125
125
  if (!task)
126
- throw new Error('task 不能为空');
126
+ throw new Error('task must not be empty');
127
127
  // 官方 SubagentStartRequest.parent 必填:调用方 agent 缺失时给结构化错误,不把 undefined 透传下去。
128
128
  if (!exec || !exec.agent)
129
- throw new Error('缺少调用方 agent(exec.agent 不可用):无法创建子代理运行');
129
+ throw new Error('no caller agent (exec.agent is unavailable): cannot create a subagent run');
130
130
  const docs = await subagents.list();
131
131
  const { allowed, reason } = await filterBySceneBinding(docs, await subagents.sceneLists());
132
132
  const persona = allowed.find((p) => p.name === name);
133
133
  if (!persona) {
134
- const available = allowed.map((p) => p.name).join('、') || '(无)';
135
- throw new Error('人设不可用: ' + name + (reason ? '(' + reason + ')' : '(可用: ' + available + ')'));
134
+ const available = allowed.map((p) => p.name).join(', ') || '(none)';
135
+ throw new Error('persona unavailable: ' + name + (reason ? ' (' + reason + ')' : ' (available: ' + available + ')'));
136
136
  }
137
137
  // 这里**刻意没有**"深度不够就拒绝"的检查(2026-09-17 用户裁定,方案 A)。
138
138
  // 它此前基于一个错误假设:以为传 `maxDepth: 1` 会让子会话再委派必然失败。实际上官方
139
- // `dsh-tool-subagent` 的默认是 **3**、provider 只在**传了值**时才校验,所以子代理本来
140
- // 就能继续嵌套。那个检查的唯一净效果是让**我们的**工具比官方严 —— 同一个子会话里官方
141
- // 工具能委派、我们的被自己拦下 —— 而用户真想禁止嵌套也禁止不了(官方工具照样能)。
139
+ // 的 `maxDepth` 只用**宿主配置**那一档(0.2.1 默认 1)、provider 只在**传了值**时才校验,
140
+ // 所以能不能继续委派问官方就行。那个检查的唯一净效果是让**我们的**工具比官方严 ——
141
+ // 同一个子会话里官方工具能委派、我们的被自己拦下 —— 而用户真想禁止嵌套也禁止不了
142
+ // (官方工具照样能)。
142
143
  // `catalogDepth` 现在只管"目录注入到哪些会话",不参与委派可行性判断。
143
144
  // 按当前会话的 Agent 预设算工具限制:判断不了当前预设、或名单里的工具已经不存在时,
144
145
  // 结论里会带一句实话,跟着结果一起返回——不静默改变限制的强度。
@@ -149,14 +150,20 @@ export function defineSubagentManagerRunTool(subagents) {
149
150
  const prefix = r.stopReason && r.stopReason !== 'completed' ? `[stopReason: ${r.stopReason}]\n` : '';
150
151
  const note = decision && decision.note ? `⚠ ${decision.note}\n\n` : '';
151
152
  // 允许追问通道(人设 `continuable: true`):产出是**异步**到达的,这里只拿到子会话 id。
152
- // 回执必须把"id + 怎么追问 + 怎么中止"三件事写全 —— 少写一句,模型就会把"没拿到结果"
153
- // 读成"委派失败"而重试一次(那会再起一个后台子代理,白白翻倍)。
153
+ // 回执必须把"id + 产出怎么回来 + 怎么追问 + 怎么中止"写全 —— 少写一句,模型就会把
154
+ // "没拿到结果"读成"委派失败"而重试一次(那会再起一个后台子代理,白白翻倍)。
155
+ // "产出怎么回来"说的是**宿主的结算通知**(0.2.1 核实 `dsh-subagent/lib/types/manager.js`
156
+ // 的 `notifySettlement` → `createSettlementMessage`):子代理结束时父代理会收到一条
157
+ // `subagent-settled` 通知,含收尾一行说明 + 子代理最终的非空 text 块(+ 结构化结果与
158
+ // 诊断)。它自己发回来的 `send_message` 是**补充**(`withContinuableReturnGuidance`
159
+ // 要求它在收尾前把自包含结果发给父代理),不是产出的唯一来路 —— 回执不能只说前者,
160
+ // 否则子代理没主动发消息时父代理会把"还没收到结果"当成"还在跑"。
154
161
  if (r.continuable === true) {
155
- return note + '已启动允许追问的子代理「' + name + '」(后台运行)\n'
162
+ return note + 'started the continuable subagent "' + name + '" (running in the background)\n'
156
163
  + 'agent_id: ' + r.runId + '\n\n'
157
- + '它的产出稍后自己发到这里,届时你会收到一条通知。要追问就 '
158
- + 'send_message({ agent_id: "' + r.runId + '", message: "..." }),'
159
- + '要中止就 interrupt_agent({ agent_id: "' + r.runId + '" })。';
164
+ + 'you will get a notification carrying its final output when it settles. To follow up: '
165
+ + 'send_message({ agent_id: "' + r.runId + '", message: "..." }), '
166
+ + 'To stop it: interrupt_agent({ agent_id: "' + r.runId + '" }).';
160
167
  }
161
168
  return note + prefix + r.text;
162
169
  },
package/lib/tools/deps.js CHANGED
@@ -7,7 +7,7 @@ export function text(value) {
7
7
  return [{ type: 'text', text: value }];
8
8
  }
9
9
  /**
10
- * 名单渲染:超过 `TOOL_NAME_LIST_CAP` 项就截断,并把总数写在后面("等 N 项")。
10
+ * 名单渲染:超过 `TOOL_NAME_LIST_CAP` 项就截断,并把总数写在后面("…(N total)")。
11
11
  *
12
12
  * 为什么两个地方都要它:`scene_manager_list` 用它列档案,`scene_manager_save` 用它
13
13
  * 在回执里回显"这一次替换挤掉了什么"。两处都必须**点名、不能只给数量** —— 只给数量
@@ -17,9 +17,9 @@ export function text(value) {
17
17
  export const TOOL_NAME_LIST_CAP = 6;
18
18
  export function toolNameList(list) {
19
19
  if (!list.length)
20
- return '空';
21
- const head = list.slice(0, TOOL_NAME_LIST_CAP).join('、');
22
- return list.length > TOOL_NAME_LIST_CAP ? head + ' 等 ' + list.length + ' 项' : head;
20
+ return 'none';
21
+ const head = list.slice(0, TOOL_NAME_LIST_CAP).join(', ');
22
+ return list.length > TOOL_NAME_LIST_CAP ? head + ' ...(' + list.length + ' total)' : head;
23
23
  }
24
24
  /**
25
25
  * 按名字找技能落在哪个来源(root)。