dsh-plugin-tool-management 0.6.0 → 0.8.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 +179 -0
- package/README.md +117 -221
- package/README_EN.md +116 -220
- package/docs/images/1/345/234/272/346/231/257.png +0 -0
- package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
- package/docs/images/2MCP.png +0 -0
- package/docs/images/2MCP_en.png +0 -0
- package/docs/images/3/346/212/200/350/203/275.png +0 -0
- package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
- package/docs/images/6/350/256/260/345/277/206.png +0 -0
- package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
- package/docs/images/7/344/274/232/350/257/235.png +0 -0
- package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
- package/docs/images/8/345/205/274/345/256/271.png +0 -0
- package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
- package/docs/update.md +56 -38
- package/lib/agents-md/service.js +84 -5
- package/lib/client.js +1488 -475
- package/lib/compat/preset-reach.js +425 -0
- package/lib/compat/probe.js +8 -0
- package/lib/http-fence.js +21 -0
- package/lib/hub.js +160 -2
- package/lib/index.js +687 -47
- package/lib/mcp/override-blocks.js +195 -0
- package/lib/mcp/state-section.js +90 -0
- package/lib/prompt-sections.js +148 -0
- package/lib/rules/archive-engine.js +99 -29
- package/lib/rules/archive.js +56 -28
- package/lib/rules/service.js +332 -35
- package/lib/skills/core.js +15 -1
- package/lib/skills/service.js +3 -0
- package/lib/subagents/catalog.js +89 -0
- package/lib/subagents/service.js +384 -41
- package/lib/subagents/tools.js +18 -5
- package/package.json +97 -96
- package/screenshots.json +10 -10
- package/docs/Changelog.md +0 -144
- package/lib/rules/provider.js +0 -121
package/lib/subagents/service.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// v0.4 目录变更:人设由 `$DSH_HOME/subagents/` 搬到 `$DSH_HOME/tool-management/agents/`
|
|
6
6
|
// (插件产生的文件统一收在 tool-management/ 下)。旧目录在首次扫描时搬入,见 relocateLegacyPersonas。
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
|
-
import { mkdir, readdir, readFile, rename,
|
|
8
|
+
import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { resolveDshHome } from '../skills/core.js';
|
|
11
|
+
import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
|
|
11
12
|
import { expandUploads, planPersonaImport } from '../imports/upload.js';
|
|
12
13
|
const message = (e) => String((e && e.message) || e);
|
|
13
14
|
/** 人设默认目录:`$DSH_HOME/tool-management/agents/`。 */
|
|
@@ -50,25 +51,66 @@ async function relocateLegacyPersonas(dir) {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
|
-
/**
|
|
54
|
+
/** 逗号/顿号分隔的名字清单(空串 → undefined)。 */
|
|
55
|
+
function listOf(value) {
|
|
56
|
+
const text = String(value ?? '').trim();
|
|
57
|
+
if (!text)
|
|
58
|
+
return undefined;
|
|
59
|
+
const list = text.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
|
60
|
+
return list.length ? list : undefined;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 解析 `toolsByPreset:` 块 —— 每个 Agent 预设一行:
|
|
64
|
+
*
|
|
65
|
+
* ```yaml
|
|
66
|
+
* toolsByPreset:
|
|
67
|
+
* standard: allow: read, write
|
|
68
|
+
* ptc: deny: workflow
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* 缩进即"在块内",回到顶格即块结束(frontmatter 只有这一处嵌套,所以不需要完整
|
|
72
|
+
* YAML 解析器)。模式 id 只做形状校验,认不认得由运行时按当前预设名单判断。
|
|
73
|
+
*/
|
|
74
|
+
export function parsePresetToolRules(frontmatter) {
|
|
75
|
+
const rules = {};
|
|
76
|
+
let inBlock = false;
|
|
77
|
+
for (const raw of frontmatter.split(/\r?\n/)) {
|
|
78
|
+
const line = raw.replace(/\s+#.*$/, '');
|
|
79
|
+
if (!inBlock) {
|
|
80
|
+
if (/^toolsbypreset\s*:\s*$/i.test(line.trim()))
|
|
81
|
+
inBlock = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (line.trim() === '')
|
|
85
|
+
continue;
|
|
86
|
+
if (!/^\s/.test(line))
|
|
87
|
+
break;
|
|
88
|
+
const m = /^\s*([A-Za-z0-9._-]+)\s*:\s*(allow|deny)\s*:\s*(.*)$/i.exec(line);
|
|
89
|
+
if (!m)
|
|
90
|
+
continue;
|
|
91
|
+
const names = listOf(m[3]);
|
|
92
|
+
// 空名单 = 不限制 = 不落盘,读到时也直接忽略,免得把"没开"读成"开了但空"。
|
|
93
|
+
if (!names)
|
|
94
|
+
continue;
|
|
95
|
+
rules[m[1]] = { mode: m[2].toLowerCase() === 'deny' ? 'deny' : 'allow', names };
|
|
96
|
+
}
|
|
97
|
+
return Object.keys(rules).length ? rules : undefined;
|
|
98
|
+
}
|
|
99
|
+
/** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny / toolsByPreset。 */
|
|
54
100
|
export function parsePersona(raw, fallbackName) {
|
|
55
101
|
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
|
|
56
102
|
const data = {};
|
|
57
103
|
let body = raw;
|
|
104
|
+
let toolsByPreset;
|
|
58
105
|
if (m) {
|
|
59
106
|
for (const line of m[1].split(/\r?\n/)) {
|
|
60
107
|
const kv = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(line.trim());
|
|
61
108
|
if (kv)
|
|
62
109
|
data[kv[1].toLowerCase()] = kv[2].trim();
|
|
63
110
|
}
|
|
111
|
+
toolsByPreset = parsePresetToolRules(m[1]);
|
|
64
112
|
body = raw.slice(m[0].length);
|
|
65
113
|
}
|
|
66
|
-
const listOf = (value) => {
|
|
67
|
-
if (!value)
|
|
68
|
-
return undefined;
|
|
69
|
-
const list = value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
|
70
|
-
return list.length ? list : undefined;
|
|
71
|
-
};
|
|
72
114
|
const tools = listOf(data.tools);
|
|
73
115
|
// 兼容两种写法:`toolsdeny: a, b`(线上格式)与 `toolsDeny: a, b`(键名统一小写后同形)。
|
|
74
116
|
const toolsDeny = listOf(data.toolsdeny);
|
|
@@ -80,6 +122,7 @@ export function parsePersona(raw, fallbackName) {
|
|
|
80
122
|
model: data.model || undefined,
|
|
81
123
|
tools,
|
|
82
124
|
toolsDeny,
|
|
125
|
+
...(toolsByPreset === undefined ? {} : { toolsByPreset }),
|
|
83
126
|
body: body.trim(),
|
|
84
127
|
path: '',
|
|
85
128
|
};
|
|
@@ -87,26 +130,98 @@ export function parsePersona(raw, fallbackName) {
|
|
|
87
130
|
const RESULT_MAX = 16 * 1024;
|
|
88
131
|
export function createSubagentService(ctx, opts) {
|
|
89
132
|
const dir = opts?.subagentsDir || defaultPersonasDir();
|
|
133
|
+
const stateDir = opts?.stateDir && opts.stateDir.trim() !== '' ? opts.stateDir : join(resolveDshHome(), 'tool-management');
|
|
134
|
+
const stateFile = join(stateDir, 'agents-index.json');
|
|
90
135
|
const req = createRequire(import.meta.url);
|
|
91
136
|
let cache = null;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
137
|
+
// ── 人设启用集合(子智能体开关)────────────────────────────────────────
|
|
138
|
+
// agents-index.json:{ version: 1, enabled: string[] }(与 rules-index.json 同目录约定)。
|
|
139
|
+
// - 文件缺失/损坏 = **全部启用**(老用户升级零感知,行为与开关上线前一致);
|
|
140
|
+
// - 文件一旦写出即为权威:之后新建/导入/回收站恢复的人设**自动启用**(刚建就想用是常理);
|
|
141
|
+
// - 停用只影响注入与 subagent_* 工具的可见性,人设文件一个字节不动。
|
|
142
|
+
// 缓存约定:undefined = 还没读过盘;null = 文件缺失(全部启用);数组 = 权威集合。
|
|
143
|
+
let enabledCache = undefined;
|
|
144
|
+
async function readEnabled() {
|
|
145
|
+
if (enabledCache !== undefined)
|
|
146
|
+
return enabledCache;
|
|
147
|
+
// 用局部变量过渡:await 之后 TS 对闭包级缓存变量的收窄会失效,直接返回会报 undefined。
|
|
148
|
+
let next;
|
|
98
149
|
try {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
150
|
+
const raw = JSON.parse(await readFile(stateFile, 'utf8'));
|
|
151
|
+
next = Array.isArray(raw && raw.enabled) ? raw.enabled.map((x) => String(x)) : [];
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
next = null;
|
|
155
|
+
}
|
|
156
|
+
enabledCache = next;
|
|
157
|
+
return next;
|
|
158
|
+
}
|
|
159
|
+
async function writeEnabled(list) {
|
|
160
|
+
await mkdir(stateDir, { recursive: true });
|
|
161
|
+
await writeFile(stateFile, JSON.stringify({ version: 1, enabled: list }, null, 2), 'utf8');
|
|
162
|
+
enabledCache = list;
|
|
163
|
+
}
|
|
164
|
+
/** 新建/导入/恢复的人设默认启用;文件缺失(本来就全启用)时无需写。 */
|
|
165
|
+
async function enablePersonaNames(names) {
|
|
166
|
+
const set = await readEnabled();
|
|
167
|
+
if (set === null)
|
|
168
|
+
return;
|
|
169
|
+
let dirty = false;
|
|
170
|
+
for (const n of names)
|
|
171
|
+
if (set.indexOf(n) < 0) {
|
|
172
|
+
set.push(n);
|
|
173
|
+
dirty = true;
|
|
174
|
+
}
|
|
175
|
+
if (dirty)
|
|
176
|
+
await writeEnabled(set);
|
|
177
|
+
}
|
|
178
|
+
/** 改名跟随:旧名在集合里就改新名;不在(停用中)保持停用。 */
|
|
179
|
+
async function renamePersonaInEnabled(from, to) {
|
|
180
|
+
const set = await readEnabled();
|
|
181
|
+
if (set === null)
|
|
182
|
+
return;
|
|
183
|
+
const i = set.indexOf(from);
|
|
184
|
+
if (i < 0)
|
|
185
|
+
return;
|
|
186
|
+
set[i] = to;
|
|
187
|
+
await writeEnabled(set);
|
|
188
|
+
}
|
|
189
|
+
/** 删除后从集合摘掉,别留悬空名(列表时无害,但会让集合越攒越脏)。 */
|
|
190
|
+
async function removePersonaFromEnabled(name) {
|
|
191
|
+
const set = await readEnabled();
|
|
192
|
+
if (set === null || set.indexOf(name) < 0)
|
|
193
|
+
return;
|
|
194
|
+
await writeEnabled(set.filter((n) => n !== name));
|
|
195
|
+
}
|
|
196
|
+
/** list() 输出统一附上 enabled:文件缺失 → 全 true;否则按集合。 */
|
|
197
|
+
function withEnabled(docs, set) {
|
|
198
|
+
if (set === null)
|
|
199
|
+
return docs.map((d) => ({ ...d, enabled: true }));
|
|
200
|
+
return docs.map((d) => ({ ...d, enabled: set.indexOf(d.name) >= 0 }));
|
|
201
|
+
}
|
|
202
|
+
async function list() {
|
|
203
|
+
let docs;
|
|
204
|
+
if (cache && Date.now() - cache.at < 1000) {
|
|
205
|
+
docs = cache.value;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
// 旧目录搬家放在首次扫描前(幂等;失败不阻断,旧目录仍会被下面的读取兜底看到)。
|
|
209
|
+
await relocateLegacyPersonas(dir).catch(() => undefined);
|
|
210
|
+
const fresh = [];
|
|
211
|
+
try {
|
|
212
|
+
const entries = await readdir(dir);
|
|
213
|
+
for (const fileName of entries.filter((e) => e.endsWith('.md')).sort()) {
|
|
214
|
+
const raw = await readFile(join(dir, fileName), 'utf8').catch(() => '');
|
|
215
|
+
if (!raw.trim())
|
|
216
|
+
continue;
|
|
217
|
+
fresh.push({ ...parsePersona(raw, fileName.slice(0, -3)), path: join(dir, fileName) });
|
|
218
|
+
}
|
|
105
219
|
}
|
|
220
|
+
catch { /* 目录缺失 = 无人设 */ }
|
|
221
|
+
cache = { at: Date.now(), value: fresh };
|
|
222
|
+
docs = fresh;
|
|
106
223
|
}
|
|
107
|
-
|
|
108
|
-
cache = { at: Date.now(), value: docs };
|
|
109
|
-
return docs;
|
|
224
|
+
return withEnabled(docs, await readEnabled());
|
|
110
225
|
}
|
|
111
226
|
// spawn provider 在场性(设计 §3.2,落地方式见 cordis.patch.yml 的取舍注释):
|
|
112
227
|
// 官方通道探测(ctx.subagents.list())→ 缺失才挂载 → 失败把原因原样带出(不吞、不谎报)。
|
|
@@ -155,8 +270,14 @@ export function createSubagentService(ctx, opts) {
|
|
|
155
270
|
throw new Error('spawn provider 挂载后仍未出现在 ctx.subagents.list()(宿主版本不匹配?)');
|
|
156
271
|
return runtime;
|
|
157
272
|
}
|
|
158
|
-
async function runOnce(parentAgent, p, task, signal) {
|
|
273
|
+
async function runOnce(parentAgent, p, task, signal, toolFilter) {
|
|
159
274
|
const runtime = await ensureSpawnProvider();
|
|
275
|
+
// 工具限制三态:对象 = 调用方已按当前预设决定;null = 调用方明确决定不加限制;
|
|
276
|
+
// undefined = 调用方没决定(如别的入口直接调 runSerial)→ 回落人设里的旧格式全局名单。
|
|
277
|
+
const legacyFilter = (p.tools?.length || p.toolsDeny?.length)
|
|
278
|
+
? { ...(p.tools?.length ? { allow: p.tools } : {}), ...(p.toolsDeny?.length ? { deny: p.toolsDeny } : {}) }
|
|
279
|
+
: null;
|
|
280
|
+
const resolved = toolFilter === undefined ? legacyFilter : toolFilter;
|
|
160
281
|
// 官方签名:start(name, request) —— name = ctx.subagents 上的 provider 注册名。
|
|
161
282
|
const run = await runtime.start('spawn', {
|
|
162
283
|
label: p.name,
|
|
@@ -166,10 +287,8 @@ export function createSubagentService(ctx, opts) {
|
|
|
166
287
|
prompt: [{ type: 'text', text: task }],
|
|
167
288
|
persona: p.body,
|
|
168
289
|
// 工具白/黑名单 → 官方 ToolRestriction(`deny` 优先级高于 `allow`;未知名官方会直接拒绝启动,
|
|
169
|
-
//
|
|
170
|
-
...(
|
|
171
|
-
? { toolFilter: { ...(p.tools?.length ? { allow: p.tools } : {}), ...(p.toolsDeny?.length ? { deny: p.toolsDeny } : {}) } }
|
|
172
|
-
: {}),
|
|
290
|
+
// 所以名单由 index.ts 按当前预设 + 当前真实存在的工具名算好再传进来,见 decideToolFilter)。
|
|
291
|
+
...(resolved === null ? {} : { toolFilter: resolved }),
|
|
173
292
|
maxDepth: 1,
|
|
174
293
|
// provider 与 model 是模型路由的两半:DSH 的 resolveModel(provider, model) 不做
|
|
175
294
|
// `provider/model` 字符串拆分,只改 model 会落在**主会话的 provider** 上——跨来源
|
|
@@ -197,8 +316,8 @@ export function createSubagentService(ctx, opts) {
|
|
|
197
316
|
}
|
|
198
317
|
// v1 串行:同一时刻至多一个子代理运行(设计 §3.2)。
|
|
199
318
|
let chain = Promise.resolve();
|
|
200
|
-
const runSerial = (parentAgent, p, task, signal) => {
|
|
201
|
-
const queued = chain.then(() => runOnce(parentAgent, p, task, signal), () => runOnce(parentAgent, p, task, signal));
|
|
319
|
+
const runSerial = (parentAgent, p, task, signal, toolFilter) => {
|
|
320
|
+
const queued = chain.then(() => runOnce(parentAgent, p, task, signal, toolFilter), () => runOnce(parentAgent, p, task, signal, toolFilter));
|
|
202
321
|
chain = queued.catch(() => undefined);
|
|
203
322
|
return queued;
|
|
204
323
|
};
|
|
@@ -207,7 +326,7 @@ export function createSubagentService(ctx, opts) {
|
|
|
207
326
|
const docs = await list();
|
|
208
327
|
return {
|
|
209
328
|
ok: true,
|
|
210
|
-
subagents: docs.map((p) => ({ name: p.name, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null })),
|
|
329
|
+
subagents: docs.map((p) => ({ name: p.name, enabled: p.enabled !== false, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null, toolsByPreset: p.toolsByPreset ?? null })),
|
|
211
330
|
};
|
|
212
331
|
},
|
|
213
332
|
'subagent-get': async (args) => {
|
|
@@ -216,7 +335,7 @@ export function createSubagentService(ctx, opts) {
|
|
|
216
335
|
const p = docs.find((d) => d.name === name);
|
|
217
336
|
if (!p)
|
|
218
337
|
return { ok: false, error: `人设不存在: ${name}` };
|
|
219
|
-
return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], body: p.body } };
|
|
338
|
+
return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], toolsByPreset: p.toolsByPreset ?? {}, body: p.body } };
|
|
220
339
|
},
|
|
221
340
|
'subagent-create': async (args) => {
|
|
222
341
|
const name = String((args && args.name) || '').trim();
|
|
@@ -235,9 +354,17 @@ export function createSubagentService(ctx, opts) {
|
|
|
235
354
|
return { ok: false, error: `创建人设目录失败: ${dir}(${message(e)})` };
|
|
236
355
|
}
|
|
237
356
|
await writeFile(target, serializePersona(args), 'utf8');
|
|
357
|
+
// 新建即启用(文件缺失 = 本来全启用,无需写);缓存失效让下次 list 带上新成员。
|
|
358
|
+
await enablePersonaNames([name]).catch(() => undefined);
|
|
238
359
|
cache = null;
|
|
239
360
|
return { ok: true, name };
|
|
240
361
|
},
|
|
362
|
+
/**
|
|
363
|
+
* 保存人设,**可选改名**(nextName)。
|
|
364
|
+
* 人设名就是文件名(`agents/<名>.md`),所以改名 = 重命名文件;
|
|
365
|
+
* 目标名已存在直接拒绝,绝不覆盖别人的文件。
|
|
366
|
+
* 返回 `renamedFrom` 供调用方把场景档案里的绑定一起改掉(档案存的是人设名)。
|
|
367
|
+
*/
|
|
241
368
|
'subagent-update': async (args) => {
|
|
242
369
|
const name = String((args && args.name) || '').trim();
|
|
243
370
|
if (!validPersonaName(name))
|
|
@@ -246,17 +373,84 @@ export function createSubagentService(ctx, opts) {
|
|
|
246
373
|
const exists = await readFile(target, 'utf8').then(() => true).catch(() => false);
|
|
247
374
|
if (!exists)
|
|
248
375
|
return { ok: false, error: `人设不存在: ${name}` };
|
|
249
|
-
|
|
376
|
+
let finalName = name;
|
|
377
|
+
let renamedFrom;
|
|
378
|
+
const nextRaw = args && args.nextName !== undefined ? String(args.nextName).trim() : '';
|
|
379
|
+
if (nextRaw !== '' && nextRaw !== name) {
|
|
380
|
+
if (!validPersonaName(nextRaw)) {
|
|
381
|
+
return { ok: false, error: `新人设名不合法: ${nextRaw}(非空、≤64 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)` };
|
|
382
|
+
}
|
|
383
|
+
const nextTarget = join(dir, nextRaw + '.md');
|
|
384
|
+
const taken = await readFile(nextTarget, 'utf8').then(() => true).catch(() => false);
|
|
385
|
+
if (taken)
|
|
386
|
+
return { ok: false, error: `人设已存在: ${nextRaw}` };
|
|
387
|
+
try {
|
|
388
|
+
await rename(target, nextTarget);
|
|
389
|
+
}
|
|
390
|
+
catch (e) {
|
|
391
|
+
return { ok: false, error: `人设改名失败: ${message(e)}` };
|
|
392
|
+
}
|
|
393
|
+
finalName = nextRaw;
|
|
394
|
+
renamedFrom = name;
|
|
395
|
+
}
|
|
396
|
+
await writeFile(join(dir, finalName + '.md'), serializePersona({ ...args, name: finalName }), 'utf8');
|
|
397
|
+
if (renamedFrom)
|
|
398
|
+
await renamePersonaInEnabled(renamedFrom, finalName).catch(() => undefined);
|
|
250
399
|
cache = null;
|
|
251
|
-
return { ok: true, name };
|
|
400
|
+
return { ok: true, name: finalName, ...(renamedFrom ? { renamedFrom } : {}) };
|
|
252
401
|
},
|
|
402
|
+
/**
|
|
403
|
+
* 删除人设 = **移入回收站**(`hub/trash/agents-trash/<id>/persona.md`)。
|
|
404
|
+
* 早先这里是 `rm`,而界面按钮写着「移入回收站」——文案与行为不一致;现在行为追上文案,
|
|
405
|
+
* 误删可以从子智能体页的回收站里恢复。
|
|
406
|
+
*/
|
|
253
407
|
'subagent-delete': async (args) => {
|
|
254
408
|
const name = String((args && args.name) || '').trim();
|
|
255
409
|
if (!validPersonaName(name))
|
|
256
410
|
return { ok: false, error: `人设名不合法: ${name || '(空)'}` };
|
|
257
|
-
|
|
411
|
+
const target = join(dir, name + '.md');
|
|
412
|
+
const exists = await readFile(target, 'utf8').then(() => true).catch(() => false);
|
|
413
|
+
if (!exists)
|
|
414
|
+
return { ok: false, error: `人设不存在: ${name}` };
|
|
415
|
+
const moved = await moveToTrash('agents', name, [{ from: target, dest: 'persona.md' }]);
|
|
416
|
+
if (moved.ok === false)
|
|
417
|
+
return { ok: false, error: `移入回收站失败: ${moved.error}` };
|
|
418
|
+
await removePersonaFromEnabled(name).catch(() => undefined);
|
|
258
419
|
cache = null;
|
|
259
|
-
return { ok: true, name };
|
|
420
|
+
return { ok: true, name, trashId: moved.id };
|
|
421
|
+
},
|
|
422
|
+
'subagent-trash-list': async () => ({ ok: true, trash: await listTrashEntries('agents') }),
|
|
423
|
+
'subagent-trash-restore': async (args) => {
|
|
424
|
+
const id = String((args && args.id) || '').trim();
|
|
425
|
+
const entry = await readTrashEntry('agents', id);
|
|
426
|
+
if (!entry)
|
|
427
|
+
return { ok: false, error: `回收站条目不存在: ${id}` };
|
|
428
|
+
if (!validPersonaName(entry.name))
|
|
429
|
+
return { ok: false, error: `回收站里的人设名不合法: ${entry.name}` };
|
|
430
|
+
const target = join(dir, entry.name + '.md');
|
|
431
|
+
const exists = await readFile(target, 'utf8').then(() => true).catch(() => false);
|
|
432
|
+
// 绝不覆盖:同名人设已经存在时如实拒绝,让人自己决定怎么办。
|
|
433
|
+
if (exists)
|
|
434
|
+
return { ok: false, error: `无法恢复,同名人设已存在: ${entry.name}` };
|
|
435
|
+
try {
|
|
436
|
+
await mkdir(dir, { recursive: true });
|
|
437
|
+
await moveOutOfTrash('agents', id, 'persona.md', target);
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
return { ok: false, error: `恢复失败: ${message(e)}` };
|
|
441
|
+
}
|
|
442
|
+
await purgeTrashEntry('agents', id);
|
|
443
|
+
// 恢复 = 拿回来用:自动启用(文件缺失本来就全启用,无需写)。
|
|
444
|
+
await enablePersonaNames([entry.name]).catch(() => undefined);
|
|
445
|
+
cache = null;
|
|
446
|
+
return { ok: true, name: entry.name };
|
|
447
|
+
},
|
|
448
|
+
'subagent-trash-delete': async (args) => {
|
|
449
|
+
const id = String((args && args.id) || '').trim();
|
|
450
|
+
const gone = await purgeTrashEntry('agents', id);
|
|
451
|
+
if (!gone)
|
|
452
|
+
return { ok: false, error: `回收站条目不存在: ${id}` };
|
|
453
|
+
return { ok: true, id };
|
|
260
454
|
},
|
|
261
455
|
/**
|
|
262
456
|
* 导入人设(.md / .zip):一个 .md 一个人设,文件名(去扩展名)= 人设名;
|
|
@@ -297,25 +491,115 @@ export function createSubagentService(ctx, opts) {
|
|
|
297
491
|
}
|
|
298
492
|
imported.push(target.name);
|
|
299
493
|
}
|
|
300
|
-
if (imported.length)
|
|
494
|
+
if (imported.length) {
|
|
495
|
+
await enablePersonaNames(imported).catch(() => undefined);
|
|
301
496
|
cache = null;
|
|
497
|
+
}
|
|
302
498
|
return { ok: true, imported, skipped };
|
|
303
499
|
},
|
|
500
|
+
/**
|
|
501
|
+
* 子智能体开关(v0.8):停用 = 不注入目录段、subagent_list/run 不可见;文件本体不动。
|
|
502
|
+
* `enabled` 必须显式给布尔值 —— 与 rules-toggle 同一条口径:不按"翻转"推断,
|
|
503
|
+
* 免得参数丢了时界面以为改了、实际什么都没动。
|
|
504
|
+
*/
|
|
505
|
+
'subagent-toggle': async (args) => {
|
|
506
|
+
const name = String((args && args.name) || '').trim();
|
|
507
|
+
if (!validPersonaName(name))
|
|
508
|
+
return { ok: false, error: `人设名不合法: ${name || '(空)'}` };
|
|
509
|
+
if (typeof (args && args.enabled) !== 'boolean') {
|
|
510
|
+
return { ok: false, error: '缺少参数:enabled 必须是布尔值(只按传入值写入,不做"翻转"推断)' };
|
|
511
|
+
}
|
|
512
|
+
const docs = await list();
|
|
513
|
+
if (!docs.some((d) => d.name === name))
|
|
514
|
+
return { ok: false, error: `人设不存在: ${name}` };
|
|
515
|
+
const set = await readEnabled();
|
|
516
|
+
// 文件缺失 = 当前全启用:首次开关时把现状物化成显式集合(否则"部分停用"无处落笔)。
|
|
517
|
+
const base = (set === null ? docs.map((d) => d.name) : set.slice()).filter((n) => docs.some((d) => d.name === n));
|
|
518
|
+
const i = base.indexOf(name);
|
|
519
|
+
if (args.enabled === true) {
|
|
520
|
+
if (i < 0)
|
|
521
|
+
base.push(name);
|
|
522
|
+
}
|
|
523
|
+
else if (i >= 0) {
|
|
524
|
+
base.splice(i, 1);
|
|
525
|
+
}
|
|
526
|
+
await writeEnabled(base);
|
|
527
|
+
cache = null;
|
|
528
|
+
return { ok: true, name, enabled: args.enabled === true };
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
const enabledStore = {
|
|
532
|
+
/** 指定名单里当前被停用的(进入模式拍快照用:只记将被启用的行,退出时精确停回)。 */
|
|
533
|
+
async disabledAmong(names) {
|
|
534
|
+
const docs = await list();
|
|
535
|
+
const state = new Map(docs.map((d) => [d.name, d.enabled !== false]));
|
|
536
|
+
return names.filter((n) => state.get(n) === false);
|
|
537
|
+
},
|
|
538
|
+
/** 批量启停;只碰给出的名字,人设已不存在的跳过(别把悬空名写进集合)。 */
|
|
539
|
+
async setEnabled(names, enabled) {
|
|
540
|
+
const docs = await list();
|
|
541
|
+
const set = await readEnabled();
|
|
542
|
+
const base = (set === null ? docs.map((d) => d.name) : set.slice()).filter((n) => docs.some((d) => d.name === n));
|
|
543
|
+
let dirty = false;
|
|
544
|
+
for (const n of names) {
|
|
545
|
+
if (!docs.some((d) => d.name === n))
|
|
546
|
+
continue;
|
|
547
|
+
const i = base.indexOf(n);
|
|
548
|
+
if (enabled && i < 0) {
|
|
549
|
+
base.push(n);
|
|
550
|
+
dirty = true;
|
|
551
|
+
}
|
|
552
|
+
if (!enabled && i >= 0) {
|
|
553
|
+
base.splice(i, 1);
|
|
554
|
+
dirty = true;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (dirty) {
|
|
558
|
+
await writeEnabled(base);
|
|
559
|
+
cache = null;
|
|
560
|
+
}
|
|
561
|
+
},
|
|
304
562
|
};
|
|
305
|
-
return { list, runSerial, ops, writeOps: new Set(['subagent-create', 'subagent-update', 'subagent-delete', 'subagent-import']) };
|
|
563
|
+
return { list, runSerial, ops, enabledStore, writeOps: new Set(['subagent-create', 'subagent-update', 'subagent-delete', 'subagent-import', 'subagent-toggle', 'subagent-trash-restore', 'subagent-trash-delete']) };
|
|
306
564
|
}
|
|
307
565
|
function validPersonaName(name) {
|
|
308
566
|
return name.length > 0 && name.length <= 64 && !name.startsWith('.') && !/[\\/<>:"|?*]/.test(name);
|
|
309
567
|
}
|
|
568
|
+
/** 字符串数组规范化(非数组/空项都丢掉)。 */
|
|
569
|
+
function toStringList(value) {
|
|
570
|
+
return Array.isArray(value) ? value.map((x) => String(x).trim()).filter(Boolean) : [];
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* 规范化前端传来的 `toolsByPreset`:丢掉空名单(空 = 不限制 = 不落盘),
|
|
574
|
+
* 模式 id 只收形状合法的(预设 id 允许字母数字点横线)。
|
|
575
|
+
*/
|
|
576
|
+
export function presetRulesOf(value) {
|
|
577
|
+
if (value === null || typeof value !== 'object')
|
|
578
|
+
return [];
|
|
579
|
+
const out = [];
|
|
580
|
+
for (const [rawId, rawRule] of Object.entries(value)) {
|
|
581
|
+
const id = String(rawId).trim();
|
|
582
|
+
if (!id || !/^[A-Za-z0-9._-]+$/.test(id))
|
|
583
|
+
continue;
|
|
584
|
+
if (rawRule === null || typeof rawRule !== 'object')
|
|
585
|
+
continue;
|
|
586
|
+
const rec = rawRule;
|
|
587
|
+
const names = toStringList(rec.names);
|
|
588
|
+
if (!names.length)
|
|
589
|
+
continue;
|
|
590
|
+
out.push([id, { mode: String(rec.mode) === 'deny' ? 'deny' : 'allow', names }]);
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
310
594
|
/** 人设文件序列化:frontmatter 只写用户填过的键;正文 = 人设提示词。 */
|
|
311
595
|
export function serializePersona(args) {
|
|
312
596
|
const description = String((args && args.description) || '').replace(/\r?\n/g, ' ').trim();
|
|
313
597
|
const provider = String((args && args.provider) || '').trim();
|
|
314
598
|
const model = String((args && args.model) || '').trim();
|
|
315
|
-
const
|
|
316
|
-
const tools = toList(args?.tools);
|
|
599
|
+
const tools = toStringList(args?.tools);
|
|
317
600
|
// 黑名单字段兼容两种入参名:toolsDeny(UI/camel)与 tools_deny(snake)。
|
|
318
|
-
const toolsDeny =
|
|
601
|
+
const toolsDeny = toStringList(args?.toolsDeny ?? args?.tools_deny);
|
|
602
|
+
const byPreset = presetRulesOf(args?.toolsByPreset);
|
|
319
603
|
const body = String((args && args.body) ?? '').trim();
|
|
320
604
|
const lines = ['---'];
|
|
321
605
|
if (description)
|
|
@@ -328,6 +612,65 @@ export function serializePersona(args) {
|
|
|
328
612
|
lines.push('tools: ' + tools.join(', '));
|
|
329
613
|
if (toolsDeny.length)
|
|
330
614
|
lines.push('toolsDeny: ' + toolsDeny.join(', '));
|
|
615
|
+
if (byPreset.length) {
|
|
616
|
+
lines.push('toolsByPreset:');
|
|
617
|
+
for (const [id, rule] of byPreset)
|
|
618
|
+
lines.push(' ' + id + ': ' + rule.mode + ': ' + rule.names.join(', '));
|
|
619
|
+
}
|
|
331
620
|
lines.push('---', '', body, '');
|
|
332
621
|
return lines.join('\n');
|
|
333
622
|
}
|
|
623
|
+
/**
|
|
624
|
+
* 按**当前会话的 Agent 预设**决定这次委派下发什么工具限制。
|
|
625
|
+
*
|
|
626
|
+
* 为什么必须按预设分:子代理跑在父会话的预设里(官方 `composeFrom(childCtx, parent.ctx)`),
|
|
627
|
+
* 而各预设的工具集合差别极大(极简模式只有持久 shell),官方 `tools.restrict()` 遇到
|
|
628
|
+
* 名单里不存在的工具名会直接抛错、子代理根本起不来。所以:
|
|
629
|
+
*
|
|
630
|
+
* - 当前预设配了名单(且名单非空)→ 用它;白名单额外并入**当时真实在跑的 MCP 工具**
|
|
631
|
+
* (官方 allow 是"清单之外全砍",不并进来会把 MCP 一起砍掉;用户裁定:子代理要能
|
|
632
|
+
* 用当前启动的 MCP);
|
|
633
|
+
* - 当前预设没配 → 回落旧的全局 `tools` / `toolsDeny`(老文件行为不变);
|
|
634
|
+
* - 名单里有已经消失的工具名 → **丢掉并在 note 里如实说明**(不接受静默失效);
|
|
635
|
+
* - 判断不了当前预设(老宿主 / 异常)→ 不按模式施加,并在 note 里说明。
|
|
636
|
+
*
|
|
637
|
+
* 三态返回:`filter: null` = 明确不加限制;`filter: {...}` = 下发该限制。
|
|
638
|
+
*/
|
|
639
|
+
export function decideToolFilter(persona, presetId, known) {
|
|
640
|
+
const rule = presetId === null ? undefined : persona.toolsByPreset?.[presetId];
|
|
641
|
+
if (rule && rule.names.length) {
|
|
642
|
+
const usable = rule.names.filter((name) => known.names.has(name));
|
|
643
|
+
const dropped = rule.names.filter((name) => !known.names.has(name));
|
|
644
|
+
const droppedNote = dropped.length ? `名单里这些工具当前不存在,已忽略:${dropped.join('、')}` : undefined;
|
|
645
|
+
if (!usable.length) {
|
|
646
|
+
return { filter: null, note: `「${presetId}」的${rule.mode === 'allow' ? '白' : '黑'}名单里没有当前存在的工具,本次不施加工具限制` };
|
|
647
|
+
}
|
|
648
|
+
if (rule.mode === 'allow')
|
|
649
|
+
return { filter: { allow: [...new Set([...usable, ...known.mcp])] }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
|
|
650
|
+
return { filter: { deny: usable }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
|
|
651
|
+
}
|
|
652
|
+
// 旧格式(全局名单):保持老行为,白名单同样并入 MCP。
|
|
653
|
+
const legacyAllow = (persona.tools || []).filter((name) => known.names.has(name));
|
|
654
|
+
const legacyDeny = (persona.toolsDeny || []).filter((name) => known.names.has(name));
|
|
655
|
+
const legacyDropped = [...(persona.tools || []), ...(persona.toolsDeny || [])].filter((name) => !known.names.has(name));
|
|
656
|
+
const filter = {
|
|
657
|
+
...(legacyAllow.length ? { allow: [...new Set([...legacyAllow, ...known.mcp])] } : {}),
|
|
658
|
+
...(legacyDeny.length ? { deny: legacyDeny } : {}),
|
|
659
|
+
};
|
|
660
|
+
const notes = [];
|
|
661
|
+
const hasLegacy = Boolean((persona.tools || []).length || (persona.toolsDeny || []).length);
|
|
662
|
+
if (presetId === null) {
|
|
663
|
+
// 说清这一刻到底靠什么在跑:有旧格式就照旧格式,没有就明说"按模式配的限制这次不生效"。
|
|
664
|
+
notes.push(hasLegacy
|
|
665
|
+
? '没能判断当前会话的 Agent 预设,按旧格式的全局名单执行'
|
|
666
|
+
: '没能判断当前会话的 Agent 预设,按模式配的限制这次不生效');
|
|
667
|
+
}
|
|
668
|
+
if (legacyDropped.length)
|
|
669
|
+
notes.push(`名单里这些工具当前不存在,已忽略:${legacyDropped.join('、')}`);
|
|
670
|
+
if (!Object.keys(filter).length) {
|
|
671
|
+
if (presetId === null && !notes.length)
|
|
672
|
+
notes.push('没能判断当前会话的 Agent 预设,本次不施加工具限制');
|
|
673
|
+
return { filter: null, ...(notes.length ? { note: notes.join(';') } : {}) };
|
|
674
|
+
}
|
|
675
|
+
return { filter, ...(notes.length ? { note: notes.join(';') } : {}) };
|
|
676
|
+
}
|
package/lib/subagents/tools.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
export function text(v) {
|
|
2
2
|
return [{ type: 'text', text: v }];
|
|
3
3
|
}
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* 可见性过滤(两道闸,按序):
|
|
6
|
+
* ① 人设开关(全局):停用的绝不进上下文 —— 即便它被场景绑定(场景启动时会自动把
|
|
7
|
+
* 绑定的人设启用,见 archive-engine 的 applySubagentSwitches)。
|
|
8
|
+
* ② 场景绑定校验(设计 §3.3):启用场景的 subagents 并集;空并集 = 全部可用。
|
|
9
|
+
* 之前只有②:全局默认所有子智能体都进上下文,没有开关可言。
|
|
10
|
+
*/
|
|
5
11
|
export async function filterBySceneBinding(docs, enabledSceneLists) {
|
|
12
|
+
const live = docs.filter((d) => d.enabled !== false);
|
|
6
13
|
const bound = new Set(enabledSceneLists.flat());
|
|
7
14
|
if (!bound.size)
|
|
8
|
-
return { allowed:
|
|
9
|
-
const allowed =
|
|
15
|
+
return { allowed: live, reason: null };
|
|
16
|
+
const allowed = live.filter((d) => bound.has(d.name));
|
|
10
17
|
return { allowed, reason: allowed.length ? null : `当前启用场景的子智能体绑定: ${[...bound].join('、')}` };
|
|
11
18
|
}
|
|
12
19
|
export function defineSubagentListTool(subagents) {
|
|
@@ -54,9 +61,15 @@ export function defineSubagentRunTool(subagents) {
|
|
|
54
61
|
const available = allowed.map((p) => p.name).join('、') || '(无)';
|
|
55
62
|
throw new Error('人设不可用: ' + name + (reason ? '(' + reason + ')' : '(可用: ' + available + ')'));
|
|
56
63
|
}
|
|
57
|
-
|
|
64
|
+
// 按当前会话的 Agent 预设算工具限制:判断不了当前预设、或名单里的工具已经不存在时,
|
|
65
|
+
// 结论里会带一句实话,跟着结果一起返回——不静默改变限制的强度。
|
|
66
|
+
const decision = typeof subagents.toolFilterFor === 'function'
|
|
67
|
+
? await subagents.toolFilterFor(persona, exec.agent && exec.agent.ctx)
|
|
68
|
+
: undefined;
|
|
69
|
+
const r = await subagents.runSerial(exec.agent, persona, task, exec.signal, decision === undefined ? undefined : decision.filter);
|
|
58
70
|
const prefix = r.stopReason && r.stopReason !== 'completed' ? `[stopReason: ${r.stopReason}]\n` : '';
|
|
59
|
-
|
|
71
|
+
const note = decision && decision.note ? `⚠ ${decision.note}\n\n` : '';
|
|
72
|
+
return note + prefix + r.text;
|
|
60
73
|
},
|
|
61
74
|
};
|
|
62
75
|
}
|