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.
- package/CHANGELOG.md +37 -0
- package/README.md +4 -1
- package/README_EN.md +4 -1
- package/docs/update.md +1 -0
- package/lib/client.js +117 -11
- package/lib/compat/capability-specs.js +60 -1
- package/lib/compat/host-contracts.js +72 -0
- package/lib/compat/host-probe.js +59 -0
- package/lib/compat/host-roots.js +1 -1
- package/lib/compat/probe.js +84 -13
- package/lib/context-inject-frame.js +134 -26
- package/lib/context-inject.js +12 -9
- package/lib/http-api.js +3 -2
- package/lib/http-fence.js +26 -1
- package/lib/index.js +5 -2
- package/lib/mcp/manager.js +6 -1
- package/lib/mcp/state-section.js +75 -34
- package/lib/memories/constants.js +39 -7
- package/lib/memories/projection.js +18 -18
- package/lib/memories/service.js +8 -7
- package/lib/memories/snapshot.js +27 -17
- package/lib/ops/compat.js +64 -2
- package/lib/ops/memory.js +2 -2
- package/lib/ops/prompts.js +30 -2
- package/lib/request-gate.js +5 -4
- package/lib/skills/catalog.js +2 -2
- package/lib/subagents/catalog.js +4 -4
- package/lib/subagents/child-preset.js +120 -0
- package/lib/subagents/persona-parse.js +9 -9
- package/lib/subagents/run-provider.js +218 -62
- package/lib/subagents/tool-filter.js +12 -12
- package/lib/subagents/tools.js +29 -22
- package/lib/tools/deps.js +4 -4
- package/lib/tools/mcp.js +34 -34
- package/lib/tools/memory.js +34 -34
- package/lib/tools/prompt.js +5 -5
- package/lib/tools/remove.js +17 -17
- package/lib/tools/scene.js +66 -66
- package/lib/tools/skills.js +21 -21
- package/lib/tools/subagent.js +13 -13
- package/lib/tools/table.js +2 -2
- 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.
|
|
37
|
-
throw new Error('
|
|
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('
|
|
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:
|
|
57
|
-
+ deps.message(e) + '
|
|
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(
|
|
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
|
-
// 这里**刻意没有**"预算用尽就拒绝"
|
|
70
|
-
//
|
|
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
|
-
// 更根本(模型选的是省力,不是没看见)。
|
|
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
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
// label 提到 spec 顶层、signal 由 spec 自己带(官方接管准备期的取消),两者都不进 request。
|
|
275
|
+
...(childId === undefined ? {} : { childId }),
|
|
129
276
|
request: {
|
|
130
277
|
parent: parentAgent,
|
|
131
|
-
prompt: [{ type: 'text', text: task }],
|
|
132
|
-
|
|
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:
|
|
282
|
+
signal: startSignal,
|
|
283
|
+
delivery: p.continuable === true ? 'parent' : 'caller',
|
|
137
284
|
});
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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(
|
|
324
|
+
runId: String(activation.childId ?? ''),
|
|
174
325
|
stopReason,
|
|
175
326
|
};
|
|
176
327
|
}
|
|
177
328
|
finally {
|
|
178
|
-
await
|
|
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
|
-
// `
|
|
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(
|
|
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(
|
|
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('
|
|
61
|
-
return { filter: null, note: note ??
|
|
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('
|
|
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
|
-
? '
|
|
85
|
-
: '
|
|
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(
|
|
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(
|
|
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('
|
|
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
|
}
|
package/lib/subagents/tools.js
CHANGED
|
@@ -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 :
|
|
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
|
|
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 = '
|
|
71
|
-
(hiddenCount > 0 ? '
|
|
72
|
-
return header + (lines.join('\n') || '(
|
|
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
|
|
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
|
|
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('
|
|
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('
|
|
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
|
-
// `
|
|
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 + '
|
|
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
|
-
+ '
|
|
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` 项就截断,并把总数写在后面("
|
|
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 + '
|
|
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)。
|