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.
- package/CHANGELOG.md +60 -1
- package/README.md +4 -1
- package/README_EN.md +4 -1
- package/docs/update.md +3 -1
- package/lib/client.js +225 -54
- 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
|
@@ -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
|
-
*
|
|
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}
|
|
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.
|
|
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
|
}
|