@ai-agent-forge/plugin-subagent-delegate 0.87.0 → 0.88.1
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/agent-forge.json +1 -1
- package/dist/delegate.d.ts +99 -1
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +672 -196
- package/dist/delegate.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -5
- package/plugin.json +1 -1
package/dist/delegate.js
CHANGED
|
@@ -177,6 +177,157 @@ function validateRoleDefinition(id, value, origin) {
|
|
|
177
177
|
...(parentView === true ? { parentView: true } : {}),
|
|
178
178
|
};
|
|
179
179
|
}
|
|
180
|
+
/** 角色菜单字符数(工具描述里每个角色占 `id — description`;装载期与注册期共用同一公式)。 */
|
|
181
|
+
function roleMenuChars(roles) {
|
|
182
|
+
let total = 0;
|
|
183
|
+
for (const role of roles)
|
|
184
|
+
total += role.id.length + role.description.length + 3;
|
|
185
|
+
return total;
|
|
186
|
+
}
|
|
187
|
+
/** 未知角色 id 的拒绝文本(resolveRole 与角色工具共用,措辞保持稳定)。 */
|
|
188
|
+
function unknownRoleMessage(id, roles) {
|
|
189
|
+
const available = [...roles.keys()].sort();
|
|
190
|
+
return (`unknown subagent role ${JSON.stringify(id)}; available roles: ` +
|
|
191
|
+
(available.length === 0 ? "(none configured)" : available.join(", ")));
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* 角色是否带**收窄档案**(profile 里除基线 version/roleId 外还有 model/thinkingLevel/
|
|
195
|
+
* tools/mode/systemPromptAppend)。`role.profile !== undefined` 不是这个意思:档案基线
|
|
196
|
+
* 恒含 roleId,因此任何角色都带 profile;"带收窄"看的是有没有收窄字段。
|
|
197
|
+
*/
|
|
198
|
+
function hasNarrowingProfile(role) {
|
|
199
|
+
const profile = role.profile;
|
|
200
|
+
return (profile !== undefined &&
|
|
201
|
+
(profile.model !== undefined ||
|
|
202
|
+
profile.thinkingLevel !== undefined ||
|
|
203
|
+
profile.tools !== undefined ||
|
|
204
|
+
profile.mode !== undefined ||
|
|
205
|
+
profile.systemPromptAppend !== undefined));
|
|
206
|
+
}
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// 会话级子代理角色(session-scoped roles,D-081,2026-10-03 用户裁决落地)
|
|
209
|
+
//
|
|
210
|
+
// 主代理可用角色 = 磁盘角色(方案 associations ∩ 目录文件)∪ 会话角色。会话角色由
|
|
211
|
+
// 主代理在会话内用 `subagent_role_set` 注册,随会话文件持久化(下次打开仍在),
|
|
212
|
+
// 不写 `<agentDir>/roles/`、不跨会话共享、子代理不继承。磁盘角色优先:撞名的注册
|
|
213
|
+
// 被结构化拒绝,装载期合并时撞名的会话角色被忽略并 warn。
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
/**
|
|
216
|
+
* 会话角色快照条目的 customType(跨插件契约,workflow 软校验按此字面量读取):
|
|
217
|
+
* data = `{ version: 1, roles: [...] }` **全量快照**、append-only;恢复时反向扫描
|
|
218
|
+
* 最近一条可解析快照(与 goal/plan/todo/workflow 的 loadState 同构)。压缩只影响
|
|
219
|
+
* LLM 上下文投影,`getBranchEntries` 始终返回完整分支,按分支重建因此压缩安全。
|
|
220
|
+
*/
|
|
221
|
+
export const SESSION_ROLE_STATE_ENTRY = "subagent.roles.state";
|
|
222
|
+
/** 运行期角色 → 会话快照条目(平铺收窄字段,读-改-写与角色文件同构)。 */
|
|
223
|
+
export function toSessionRoleRecord(role) {
|
|
224
|
+
const profile = role.profile;
|
|
225
|
+
return {
|
|
226
|
+
id: role.id,
|
|
227
|
+
description: role.description,
|
|
228
|
+
instructions: role.instructions,
|
|
229
|
+
...(profile?.model === undefined ? {} : { model: profile.model }),
|
|
230
|
+
...(profile?.thinkingLevel === undefined ? {} : { thinkingLevel: profile.thinkingLevel }),
|
|
231
|
+
...(profile?.tools === undefined ? {} : { tools: profile.tools }),
|
|
232
|
+
...(profile?.mode === undefined ? {} : { mode: profile.mode }),
|
|
233
|
+
...(profile?.systemPromptAppend === undefined ? {} : { systemPromptAppend: profile.systemPromptAppend }),
|
|
234
|
+
...(role.parentView === true ? { parentView: true } : {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* 恢复水合(D-081):从 `getBranchEntries()` 反向扫描最近一条**可解析**的会话角色
|
|
239
|
+
* 快照。快照级损坏(非对象/版本不符/roles 非数组)→ warn 后继续向前找;快照合法但
|
|
240
|
+
* 个别角色非法(id 形状、字段/档案校验不过)→ 丢弃该角色并 warn、保留其余;全部
|
|
241
|
+
* 不可解析 → 空数组。损坏不阻断会话启动。
|
|
242
|
+
*/
|
|
243
|
+
export function readSessionRoles(session, logger) {
|
|
244
|
+
const entries = session?.getBranchEntries() ?? [];
|
|
245
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
246
|
+
const entry = entries[index];
|
|
247
|
+
if (entry?.customType !== SESSION_ROLE_STATE_ENTRY)
|
|
248
|
+
continue;
|
|
249
|
+
const snapshot = entry.data;
|
|
250
|
+
if (!isPlainRecord(snapshot) || snapshot.version !== 1 || !Array.isArray(snapshot.roles)) {
|
|
251
|
+
logger?.warn?.(`session role state entry ${entry.id} is not a readable snapshot (want { version: 1, roles: [...] }); ` +
|
|
252
|
+
"reading the previous snapshot");
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const roles = [];
|
|
256
|
+
for (const [roleIndex, raw] of snapshot.roles.entries()) {
|
|
257
|
+
const origin = `${SESSION_ROLE_STATE_ENTRY}[${roleIndex}]`;
|
|
258
|
+
if (!isPlainRecord(raw) || typeof raw.id !== "string" || !ROLE_ID_PATTERN.test(raw.id)) {
|
|
259
|
+
logger?.warn?.(`${origin} has no valid role id; entry dropped`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
// id 来自条目本身;其余键原样交给与角色文件同一套校验(未知键照样拒绝)。
|
|
263
|
+
const definition = {};
|
|
264
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
265
|
+
if (key !== "id")
|
|
266
|
+
definition[key] = value;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const role = validateRoleDefinition(raw.id, definition, origin);
|
|
270
|
+
if (role !== undefined)
|
|
271
|
+
roles.push(role);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
logger?.warn?.(`${origin} (role ${JSON.stringify(raw.id)}) is invalid and was dropped: ${errorText(error)}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return Object.freeze(roles);
|
|
278
|
+
}
|
|
279
|
+
return Object.freeze([]);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* 合并磁盘角色与会话角色(D-081):**磁盘优先**——撞名的会话角色不暴露并 warn(磁盘
|
|
283
|
+
* 角色是用户资产),但其数据仍留在会话文件里;水合按快照顺序把会话角色收进暴露预算
|
|
284
|
+
* (≤16 个、菜单 ≤2000 字符,磁盘+会话合并计算),超出部分不暴露并 warn——历史快照
|
|
285
|
+
* 可能因上限收紧或磁盘角色增加而超限,但不阻断会话启动,也不删除数据。
|
|
286
|
+
*/
|
|
287
|
+
export function mergeSubagentRoles(diskRoles, sessionRoles, logger) {
|
|
288
|
+
// 数据层:快照里的合法会话角色全量保留(同 id 后者覆盖前者,与快照顺序一致)。
|
|
289
|
+
const data = new Map();
|
|
290
|
+
// 视图层:磁盘角色优先,会话角色按快照顺序通过暴露预算。
|
|
291
|
+
const roles = new Map(diskRoles);
|
|
292
|
+
const shadowedByDisk = [];
|
|
293
|
+
const droppedForQuota = [];
|
|
294
|
+
for (const role of sessionRoles) {
|
|
295
|
+
data.set(role.id, role);
|
|
296
|
+
if (diskRoles.has(role.id)) {
|
|
297
|
+
shadowedByDisk.push(role.id);
|
|
298
|
+
logger?.warn?.(`session role "${role.id}" is shadowed by a visible disk role: kept in the session file but not exposed ` +
|
|
299
|
+
"(the disk role wins)");
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const prospectiveChars = roleMenuChars([...roles.values(), role]);
|
|
303
|
+
if (roles.size + 1 > ROLE_COUNT_LIMIT || prospectiveChars > ROLE_MENU_CHARS) {
|
|
304
|
+
droppedForQuota.push(role.id);
|
|
305
|
+
logger?.warn?.(`session role "${role.id}" exceeds the ${ROLE_COUNT_LIMIT}-role / ${ROLE_MENU_CHARS}-char exposure budget: ` +
|
|
306
|
+
"kept in the session file but not exposed");
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
roles.set(role.id, role);
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
sessionRoles: data,
|
|
313
|
+
roles,
|
|
314
|
+
shadowedByDisk: Object.freeze(shadowedByDisk),
|
|
315
|
+
droppedForQuota: Object.freeze(droppedForQuota),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/** 注册点的预算错误文本(undefined = 在预算内):与装载期上限同一常量,附精简建议。 */
|
|
319
|
+
function roleQuotaViolation(roles) {
|
|
320
|
+
if (roles.size > ROLE_COUNT_LIMIT) {
|
|
321
|
+
return (`registering this session role would expose ${roles.size} roles; at most ${ROLE_COUNT_LIMIT} are allowed ` +
|
|
322
|
+
"(the menu rides in the parent's tool description on every request) — remove unused session roles first");
|
|
323
|
+
}
|
|
324
|
+
const chars = roleMenuChars(roles.values());
|
|
325
|
+
if (chars > ROLE_MENU_CHARS) {
|
|
326
|
+
return (`registering this session role would grow the role menu to ${chars} chars; at most ${ROLE_MENU_CHARS} are ` +
|
|
327
|
+
"allowed — shorten descriptions or remove session roles first");
|
|
328
|
+
}
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
180
331
|
/**
|
|
181
332
|
* 解析当前方案的角色目录(**全部来自磁盘**,无编译内定义)。装载期一次性完成,
|
|
182
333
|
* 任何会让"配置了但没生效"的情况都显式失败:
|
|
@@ -243,7 +394,7 @@ export function resolveSubagentRoleCatalog(options) {
|
|
|
243
394
|
throw new Error(`suite associations.roles exposes ${roles.size} roles; at most ${ROLE_COUNT_LIMIT} are allowed — the role menu ` +
|
|
244
395
|
"rides in the parent's tool description on every request, and an unbounded menu degrades the main agent's own context");
|
|
245
396
|
}
|
|
246
|
-
const menuChars =
|
|
397
|
+
const menuChars = roleMenuChars(roles.values());
|
|
247
398
|
if (menuChars > ROLE_MENU_CHARS) {
|
|
248
399
|
throw new Error(`suite associations.roles menu is ${menuChars} chars; at most ${ROLE_MENU_CHARS} are allowed — shorten role ` +
|
|
249
400
|
"descriptions or associate fewer roles (the menu is part of the parent's tool description)");
|
|
@@ -475,9 +626,7 @@ function resolveRole(value, roles) {
|
|
|
475
626
|
}
|
|
476
627
|
const role = roles.get(value);
|
|
477
628
|
if (role === undefined) {
|
|
478
|
-
|
|
479
|
-
throw new SubagentDelegateError("subagent_unknown_role", `unknown subagent role ${JSON.stringify(value)}; available roles: ` +
|
|
480
|
-
(available.length === 0 ? "(none configured)" : available.join(", ")));
|
|
629
|
+
throw new SubagentDelegateError("subagent_unknown_role", unknownRoleMessage(value, roles));
|
|
481
630
|
}
|
|
482
631
|
return role;
|
|
483
632
|
}
|
|
@@ -526,10 +675,10 @@ function resolveInt(explicit, configured, fallback, min, label) {
|
|
|
526
675
|
* 因此"第二参非 undefined 即输入"的判定会把 context 误当输入(prompts 读成 undefined)。
|
|
527
676
|
* 改按形状判定:第一参是普通对象即取第一参,否则看第二参。
|
|
528
677
|
*/
|
|
529
|
-
function objectInput(first, second) {
|
|
678
|
+
function objectInput(first, second, tool = "subagent_run") {
|
|
530
679
|
const primary = isPlainRecord(first) ? first : second;
|
|
531
680
|
if (!isPlainRecord(primary)) {
|
|
532
|
-
throw new SubagentDelegateError("subagent_invalid_input",
|
|
681
|
+
throw new SubagentDelegateError("subagent_invalid_input", `${tool} input must be an object`);
|
|
533
682
|
}
|
|
534
683
|
return primary;
|
|
535
684
|
}
|
|
@@ -870,17 +1019,37 @@ export function createCapability(api, options) {
|
|
|
870
1019
|
const roleCatalog = options?.roles === undefined
|
|
871
1020
|
? { roles: new Map(), dangling: [], brokenUnassociated: [] }
|
|
872
1021
|
: resolveSubagentRoleCatalog(options.roles);
|
|
873
|
-
const
|
|
1022
|
+
const diskRoles = roleCatalog.roles;
|
|
874
1023
|
for (const id of roleCatalog.dangling) {
|
|
875
1024
|
api.logger?.warn?.(`suite associations.roles lists "${id}" but no role file exists: ${subagentRoleCatalogPath(options?.roles?.agentDir ?? "", id)} (role stays unavailable for this session)`);
|
|
876
1025
|
}
|
|
877
1026
|
for (const id of roleCatalog.brokenUnassociated) {
|
|
878
1027
|
api.logger?.warn?.(`role file for "${id}" is invalid and is not associated by this suite; ignored (fix it or remove the file)`);
|
|
879
1028
|
}
|
|
880
|
-
|
|
1029
|
+
// 会话角色水合(D-081):磁盘目录解析之后从分支快照重建(最近一条可解析快照,
|
|
1030
|
+
// 损坏向前回退并 warn,全部不可解析则为空);磁盘优先、16 个 / 2000 字符菜单
|
|
1031
|
+
// (磁盘+会话合并计算)都是**暴露**预算——未暴露的项保留在数据表里,不删除。
|
|
1032
|
+
const hydratedRoles = mergeSubagentRoles(diskRoles, readSessionRoles(session, api.logger), api.logger);
|
|
1033
|
+
/**
|
|
1034
|
+
* 会话角色数据表 = 快照里的**全部**合法会话角色(含被磁盘遮蔽/未暴露项):
|
|
1035
|
+
* 先 appendEntry 成功再提交,写失败即注册失败(内存与文件不背离)。持久化写它——
|
|
1036
|
+
* 视图收窄(遮蔽/超预算)不抹掉数据。
|
|
1037
|
+
*/
|
|
1038
|
+
const sessionRoles = new Map(hydratedRoles.sessionRoles);
|
|
1039
|
+
/** 可用集 = 磁盘 ∪ 通过暴露预算的会话角色(磁盘优先):resolveRole/菜单/role 枚举只看它。 */
|
|
1040
|
+
const roles = new Map(hydratedRoles.roles);
|
|
1041
|
+
const renderRoleMenu = () => [...roles.values()]
|
|
881
1042
|
.sort((left, right) => (left.id < right.id ? -1 : 1))
|
|
882
1043
|
.map((role) => `${role.id} — ${role.description}`)
|
|
883
1044
|
.join("; ");
|
|
1045
|
+
/** 全量快照落盘(append-only):只存会话角色,绝不把磁盘角色写进条目。 */
|
|
1046
|
+
const persistSessionRoles = (next) => {
|
|
1047
|
+
const snapshot = {
|
|
1048
|
+
version: 1,
|
|
1049
|
+
roles: Object.freeze([...next].map(toSessionRoleRecord)),
|
|
1050
|
+
};
|
|
1051
|
+
session.appendEntry(SESSION_ROLE_STATE_ENTRY, snapshot);
|
|
1052
|
+
};
|
|
884
1053
|
/**
|
|
885
1054
|
* D-044 §5.6:把支持进度面的 adapter 装饰一层,累计"运行中 step"与最近一条
|
|
886
1055
|
* 摘要,供工具 onUpdate 节流上报父 UI(只回计数与摘要,不回正文)。
|
|
@@ -922,57 +1091,12 @@ export function createCapability(api, options) {
|
|
|
922
1091
|
...(limits === undefined ? {} : { limits }),
|
|
923
1092
|
...(api.logger === undefined ? {} : { logger: api.logger }),
|
|
924
1093
|
});
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
description: `1..${maxTasksPerCall} independent prompts run as separate subagent tasks`,
|
|
932
|
-
}),
|
|
933
|
-
context: Type.Optional(Type.Object({
|
|
934
|
-
anchors: Type.Optional(Type.Array(Type.Object({
|
|
935
|
-
path: Type.String({
|
|
936
|
-
minLength: 1,
|
|
937
|
-
maxLength: CONTEXT_PATH_MAX_CHARS,
|
|
938
|
-
description: "File path the child should start from",
|
|
939
|
-
}),
|
|
940
|
-
line: Type.Optional(Type.Integer({ minimum: 1, description: "Line number in path (positive integer)" })),
|
|
941
|
-
note: Type.Optional(Type.String({
|
|
942
|
-
maxLength: CONTEXT_NOTE_MAX_CHARS,
|
|
943
|
-
description: "One-line why-it-matters note",
|
|
944
|
-
})),
|
|
945
|
-
}), { maxItems: CONTEXT_LIST_MAX_ITEMS })),
|
|
946
|
-
failedAttempts: Type.Optional(Type.Array(Type.Object({
|
|
947
|
-
command: Type.String({
|
|
948
|
-
minLength: 1,
|
|
949
|
-
maxLength: CONTEXT_COMMAND_MAX_CHARS,
|
|
950
|
-
description: "Command that was already tried and failed",
|
|
951
|
-
}),
|
|
952
|
-
note: Type.Optional(Type.String({ maxLength: CONTEXT_NOTE_MAX_CHARS, description: "One-line failure note" })),
|
|
953
|
-
}), { maxItems: CONTEXT_LIST_MAX_ITEMS })),
|
|
954
|
-
}, {
|
|
955
|
-
description: "Parent pointers rendered as a header block into each child prompt: file:line anchors and commands " +
|
|
956
|
-
"already tried and failed. Pointers only — children still read the real files.",
|
|
957
|
-
})),
|
|
958
|
-
...(roles.size === 0
|
|
959
|
-
? {}
|
|
960
|
-
: {
|
|
961
|
-
role: Type.Optional(Type.String({
|
|
962
|
-
description: `Named subagent role applied to every prompt in this call; one of: ${[...roles.keys()].sort().join(", ")}. ` +
|
|
963
|
-
"Omit for a generic task. Roles frame the task; they do not change the child's model or tools.",
|
|
964
|
-
})),
|
|
965
|
-
}),
|
|
966
|
-
// background(D-060 S6)在 properties 内组合:此前的 `{...parameters, background}`
|
|
967
|
-
// 顶层展开把键放在 properties 之外,模型面 schema(anthropic 只取 schema.properties、
|
|
968
|
-
// openai 原样透传)从未真正声明该参数——D-060 裁决 13 翻转后随默认注册修正,
|
|
969
|
-
// 裁决 15 起开关形态退役、参数无条件声明。
|
|
970
|
-
background: Type.Optional(Type.Boolean({
|
|
971
|
-
description: "Submit without waiting: return task tickets immediately and keep working; settled results are " +
|
|
972
|
-
"announced at your next model round and can be read with subagent_result (optionally waiting).",
|
|
973
|
-
})),
|
|
974
|
-
});
|
|
975
|
-
const tool = api.registerTool({
|
|
1094
|
+
/**
|
|
1095
|
+
* `subagent_run` 的定义按**当前角色表**现场构建(D-081 即时生效):set/remove 之后
|
|
1096
|
+
* 以同 owner 同名重注册(runtime 语义 = 更新)让新菜单与 `role` 参数枚举在父会话的
|
|
1097
|
+
* 下一次模型请求即可见,不需要重启或新建会话。
|
|
1098
|
+
*/
|
|
1099
|
+
const buildRunToolDefinition = () => ({
|
|
976
1100
|
name: "subagent_run",
|
|
977
1101
|
label: "Run subagents",
|
|
978
1102
|
description: `Delegate 1..${maxTasksPerCall} independent subagent tasks in one call and return their bounded terminal results. ` +
|
|
@@ -990,152 +1114,499 @@ export function createCapability(api, options) {
|
|
|
990
1114
|
"status only." +
|
|
991
1115
|
(roles.size === 0
|
|
992
1116
|
? ""
|
|
993
|
-
: ` Configured subagent roles (pass \`role\`): ${
|
|
994
|
-
"
|
|
995
|
-
parameters
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1117
|
+
: ` Configured subagent roles (pass \`role\`): ${renderRoleMenu()}. A role's instructions frame every task in the call; ` +
|
|
1118
|
+
"a role with a narrowing profile also applies its tools/mode/model/thinkingLevel to the child, verified after creation."),
|
|
1119
|
+
parameters: Type.Object({
|
|
1120
|
+
prompts: Type.Array(Type.String({
|
|
1121
|
+
minLength: 1,
|
|
1122
|
+
description: "Self-contained prompt for one subagent task; the child session sees no parent history",
|
|
1123
|
+
}), {
|
|
1124
|
+
minItems: 1,
|
|
1125
|
+
description: `1..${maxTasksPerCall} independent prompts run as separate subagent tasks`,
|
|
1126
|
+
}),
|
|
1127
|
+
context: Type.Optional(Type.Object({
|
|
1128
|
+
anchors: Type.Optional(Type.Array(Type.Object({
|
|
1129
|
+
path: Type.String({
|
|
1130
|
+
minLength: 1,
|
|
1131
|
+
maxLength: CONTEXT_PATH_MAX_CHARS,
|
|
1132
|
+
description: "File path the child should start from",
|
|
1133
|
+
}),
|
|
1134
|
+
line: Type.Optional(Type.Integer({ minimum: 1, description: "Line number in path (positive integer)" })),
|
|
1135
|
+
note: Type.Optional(Type.String({
|
|
1136
|
+
maxLength: CONTEXT_NOTE_MAX_CHARS,
|
|
1137
|
+
description: "One-line why-it-matters note",
|
|
1138
|
+
})),
|
|
1139
|
+
}), { maxItems: CONTEXT_LIST_MAX_ITEMS })),
|
|
1140
|
+
failedAttempts: Type.Optional(Type.Array(Type.Object({
|
|
1141
|
+
command: Type.String({
|
|
1142
|
+
minLength: 1,
|
|
1143
|
+
maxLength: CONTEXT_COMMAND_MAX_CHARS,
|
|
1144
|
+
description: "Command that was already tried and failed",
|
|
1145
|
+
}),
|
|
1146
|
+
note: Type.Optional(Type.String({ maxLength: CONTEXT_NOTE_MAX_CHARS, description: "One-line failure note" })),
|
|
1147
|
+
}), { maxItems: CONTEXT_LIST_MAX_ITEMS })),
|
|
1148
|
+
}, {
|
|
1149
|
+
description: "Parent pointers rendered as a header block into each child prompt: file:line anchors and commands " +
|
|
1150
|
+
"already tried and failed. Pointers only — children still read the real files.",
|
|
1151
|
+
})),
|
|
1152
|
+
...(roles.size === 0
|
|
1153
|
+
? {}
|
|
1154
|
+
: {
|
|
1155
|
+
role: Type.Optional(Type.String({
|
|
1156
|
+
description: `Named subagent role applied to every prompt in this call; one of: ${[...roles.keys()].sort().join(", ")}. ` +
|
|
1157
|
+
"Omit for a generic task. Roles frame the task; a role may also narrow the child's " +
|
|
1158
|
+
"tools/mode/model (verified after creation).",
|
|
1159
|
+
})),
|
|
1160
|
+
}),
|
|
1161
|
+
// background(D-060 S6)在 properties 内组合:此前的 `{...parameters, background}`
|
|
1162
|
+
// 顶层展开把键放在 properties 之外,模型面 schema(anthropic 只取 schema.properties、
|
|
1163
|
+
// openai 原样透传)从未真正声明该参数——D-060 裁决 13 翻转后随默认注册修正,
|
|
1164
|
+
// 裁决 15 起开关形态退役、参数无条件声明。
|
|
1165
|
+
background: Type.Optional(Type.Boolean({
|
|
1166
|
+
description: "Submit without waiting: return task tickets immediately and keep working; settled results are " +
|
|
1167
|
+
"announced at your next model round and can be read with subagent_result (optionally waiting).",
|
|
1168
|
+
})),
|
|
1169
|
+
}),
|
|
1170
|
+
execute: executeSubagentRun,
|
|
1171
|
+
});
|
|
1172
|
+
const executeSubagentRun = async (first, second, _signal, onUpdate) => {
|
|
1173
|
+
const input = objectInput(first, second);
|
|
1174
|
+
const prompts = readPrompts(input.prompts, maxTasksPerCall);
|
|
1175
|
+
const role = resolveRole(input.role, roles);
|
|
1176
|
+
// context 注入(设计 §2.3):形状/边界违规在此整体拒绝;两个列表皆空归一化为
|
|
1177
|
+
// undefined,后续路径与 context 缺省完全一致。
|
|
1178
|
+
const contextBlock = renderDelegateContext(parseDelegateContext(input.context));
|
|
1179
|
+
const background = input.background === true;
|
|
1180
|
+
// 嵌套深度硬限(退役切片 2,Option A):沿 S2 血缘头回溯本会话层级,已达
|
|
1181
|
+
// maxNestedDepth 层时整次调用结构化拒绝——递归的兜底边界(不依赖模型自觉)。
|
|
1182
|
+
// resume 不涉深度(S5 裁定:续写既有会话不计新层级,走 subagent_resume)。
|
|
1183
|
+
// agentDir 单源(D-075 S4 第三批):显式 options 优先,缺省回落 api.host.agentDir。
|
|
1184
|
+
const agentDir = options?.agentDir ?? api.host.agentDir ?? "";
|
|
1185
|
+
if (agentDir !== "") {
|
|
1186
|
+
const depth = lineageDepth(session.getSessionId(), agentDir, maxNestedDepth + 2);
|
|
1187
|
+
if (depth !== undefined && depth + 1 > maxNestedDepth) {
|
|
1188
|
+
throw new SubagentDelegateError("subagent_depth_exceeded", `this session already sits at nested depth ${depth}; delegation beyond maxNestedDepth ${maxNestedDepth} is refused`);
|
|
1014
1189
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
createdSubagentCount += 1;
|
|
1039
|
-
// 首条 prompt 组装(角色框定 + 可选父投影);context 头部块(有则)在最上、
|
|
1040
|
-
// 与后续内容空一行,无块时与现状逐字节一致。字节数仍走 submit 侧 maxPromptBytes
|
|
1041
|
-
// 预算,超限按预算拒绝而不是静默截断。
|
|
1042
|
-
const composed = role === undefined
|
|
1043
|
-
? prompt
|
|
1044
|
-
: role.parentView === true
|
|
1045
|
-
? `${composeRolePrompt(role, prompt)}\n\n${composeParentView(session, session.getSessionId()) ?? ""}`.trim()
|
|
1046
|
-
: composeRolePrompt(role, prompt);
|
|
1047
|
-
try {
|
|
1048
|
-
pending.push({
|
|
1049
|
-
index,
|
|
1050
|
-
handle: runtime.submit({
|
|
1051
|
-
prompt: contextBlock === undefined ? composed : `${contextBlock}\n\n${composed}`,
|
|
1052
|
-
// 血缘(D-060 S1):父会话 id 随 metadata 过线,宿主写进子会话文件头;
|
|
1053
|
-
// 角色档案(若角色定义带收窄)一并带上。
|
|
1054
|
-
metadata: {
|
|
1055
|
-
[AGENT_TASK_PARENT_SESSION_KEY]: session.getSessionId(),
|
|
1056
|
-
...(turnCorrelationId === undefined
|
|
1057
|
-
? {}
|
|
1058
|
-
: { [AGENT_TASK_PARENT_TRACE_KEY]: turnCorrelationId }),
|
|
1059
|
-
...(role?.profile === undefined ? {} : { [AGENT_TASK_CHILD_PROFILE_KEY]: role.profile }),
|
|
1060
|
-
},
|
|
1061
|
-
}),
|
|
1062
|
-
});
|
|
1063
|
-
}
|
|
1064
|
-
catch (error) {
|
|
1065
|
-
tasks[index] = {
|
|
1066
|
-
taskId: "",
|
|
1067
|
-
status: "rejected",
|
|
1068
|
-
reasonCode: reasonCodeOf(error),
|
|
1069
|
-
reason: errorText(error),
|
|
1070
|
-
messageCount: 0,
|
|
1071
|
-
};
|
|
1072
|
-
}
|
|
1190
|
+
}
|
|
1191
|
+
// submit 是同步抛出契约(预算/队列/已释放),逐条捕获为条目级 rejected;
|
|
1192
|
+
// 全部提交完成后再逐条 drain,任务因此并行执行。
|
|
1193
|
+
const pending = [];
|
|
1194
|
+
const tasks = new Array(prompts.length).fill(undefined);
|
|
1195
|
+
// 回合级血缘(第 3 期第二批):提交动作发生在父回合工具阶段,此时 correlationId
|
|
1196
|
+
// 有值;getter 缺席或返回 undefined 时不写键(行为回退)。仅 subagent_run 新建
|
|
1197
|
+
// 路径携带——resume 打开既有会话、头定格,写键即"要求了但不生效"。
|
|
1198
|
+
const turnCorrelationId = options?.getTurnCorrelationId?.();
|
|
1199
|
+
for (let index = 0; index < prompts.length; index += 1) {
|
|
1200
|
+
const prompt = prompts[index];
|
|
1201
|
+
// 总量硬限(承载退役切片 2,Option A):本会话累计创建数达上限时按结构化
|
|
1202
|
+
// 码拒绝该条目(同批其余条目继续)。深度硬限在 execute 开头沿 S2 血缘头
|
|
1203
|
+
// 整调用拒绝(见上),不在此重复。
|
|
1204
|
+
if (createdSubagentCount >= maxTotalPerParent) {
|
|
1205
|
+
tasks[index] = {
|
|
1206
|
+
taskId: "",
|
|
1207
|
+
status: "rejected",
|
|
1208
|
+
reasonCode: "subagent_child_quota",
|
|
1209
|
+
reason: `this session already created ${createdSubagentCount} child sessions (cap ${maxTotalPerParent})`,
|
|
1210
|
+
messageCount: 0,
|
|
1211
|
+
};
|
|
1212
|
+
continue;
|
|
1073
1213
|
}
|
|
1074
|
-
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1214
|
+
createdSubagentCount += 1;
|
|
1215
|
+
// 首条 prompt 组装(角色框定 + 可选父投影);context 头部块(有则)在最上、
|
|
1216
|
+
// 与后续内容空一行,无块时与现状逐字节一致。字节数仍走 submit 侧 maxPromptBytes
|
|
1217
|
+
// 预算,超限按预算拒绝而不是静默截断。
|
|
1218
|
+
const composed = role === undefined
|
|
1219
|
+
? prompt
|
|
1220
|
+
: role.parentView === true
|
|
1221
|
+
? `${composeRolePrompt(role, prompt)}\n\n${composeParentView(session, session.getSessionId()) ?? ""}`.trim()
|
|
1222
|
+
: composeRolePrompt(role, prompt);
|
|
1223
|
+
try {
|
|
1224
|
+
pending.push({
|
|
1225
|
+
index,
|
|
1226
|
+
handle: runtime.submit({
|
|
1227
|
+
prompt: contextBlock === undefined ? composed : `${contextBlock}\n\n${composed}`,
|
|
1228
|
+
// 血缘(D-060 S1):父会话 id 随 metadata 过线,宿主写进子会话文件头。
|
|
1229
|
+
// 角色存在时 child profile 恒定携带(档案基线含 roleId,无收窄字段的角色
|
|
1230
|
+
// 同样有血缘——/agents 与 subagent_list/subagent_result 的角色血缘对全部
|
|
1231
|
+
// 角色生效);`role.profile ?? { version: 1, roleId }` 是防御兜底——对
|
|
1232
|
+
// validateRoleDefinition 产出的角色不可达;回归测试钉住该行为。
|
|
1233
|
+
metadata: {
|
|
1234
|
+
[AGENT_TASK_PARENT_SESSION_KEY]: session.getSessionId(),
|
|
1235
|
+
...(turnCorrelationId === undefined ? {} : { [AGENT_TASK_PARENT_TRACE_KEY]: turnCorrelationId }),
|
|
1236
|
+
...(role === undefined
|
|
1237
|
+
? {}
|
|
1238
|
+
: {
|
|
1239
|
+
[AGENT_TASK_CHILD_PROFILE_KEY]: role.profile ?? { version: 1, roleId: role.id },
|
|
1240
|
+
}),
|
|
1241
|
+
},
|
|
1242
|
+
}),
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
catch (error) {
|
|
1246
|
+
tasks[index] = {
|
|
1247
|
+
taskId: "",
|
|
1248
|
+
status: "rejected",
|
|
1249
|
+
reasonCode: reasonCodeOf(error),
|
|
1250
|
+
reason: errorText(error),
|
|
1251
|
+
messageCount: 0,
|
|
1252
|
+
};
|
|
1105
1253
|
}
|
|
1106
|
-
|
|
1254
|
+
}
|
|
1255
|
+
// 后台委派(D-060 S6,配置门默认关):提交即返回票据,不 drain——任务挂在
|
|
1256
|
+
// 调度器上继续跑(会话级生命周期,父栅栏/会话销毁统一回收)。每个后台任务
|
|
1257
|
+
// 挂一个结算观察器:结算一行有界 JSON 进宿主 sink,随父的下一次模型轮次注入。
|
|
1258
|
+
if (background === true) {
|
|
1259
|
+
const tickets = [];
|
|
1107
1260
|
for (const item of pending) {
|
|
1108
|
-
let taskId = "";
|
|
1109
1261
|
try {
|
|
1110
1262
|
const handle = await item.handle;
|
|
1111
|
-
taskId
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1263
|
+
tickets.push({ taskId: handle.id });
|
|
1264
|
+
const sink = options?.backgroundDigestSink;
|
|
1265
|
+
if (sink !== undefined) {
|
|
1266
|
+
void handle
|
|
1267
|
+
.result()
|
|
1268
|
+
.then((record) => {
|
|
1269
|
+
sink(JSON.stringify({
|
|
1270
|
+
taskId: record.taskId,
|
|
1271
|
+
sessionId: record.sessionId,
|
|
1272
|
+
status: record.status,
|
|
1273
|
+
...(record.reasonCode === undefined ? {} : { reasonCode: record.reasonCode }),
|
|
1274
|
+
}));
|
|
1275
|
+
})
|
|
1276
|
+
.catch(() => {
|
|
1277
|
+
// 摘要观察失败不影响任务本身(终态已在调度器落库可查)。
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1122
1280
|
}
|
|
1123
|
-
catch
|
|
1124
|
-
//
|
|
1125
|
-
|
|
1281
|
+
catch {
|
|
1282
|
+
// submit 同步抛错已在上面捕获为条目级 rejected——后台票据只收成功提交。
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
return { background: true, tasks: tickets };
|
|
1286
|
+
}
|
|
1287
|
+
let completed = tasks.filter((task) => task !== undefined).length;
|
|
1288
|
+
for (const item of pending) {
|
|
1289
|
+
let taskId = "";
|
|
1290
|
+
try {
|
|
1291
|
+
const handle = await item.handle;
|
|
1292
|
+
taskId = handle.id;
|
|
1293
|
+
const record = await handle.drain({ timeoutMs: taskTimeoutMs });
|
|
1294
|
+
// escalate 上抛(D-060 S4):随任务投影一起交回父模型(取后即清)。
|
|
1295
|
+
const escalations = wiring?.drainEscalations?.(record.sessionId) ?? [];
|
|
1296
|
+
// 兄弟流量摘录(D-060 裁决 14):只读审计面,随同一信封对父可见。
|
|
1297
|
+
const siblingTraffic = wiring?.readSiblingTraffic?.(record.sessionId, SIBLING_TRAFFIC_EXCERPTS) ?? [];
|
|
1298
|
+
tasks[item.index] = {
|
|
1299
|
+
...projectRecord(record),
|
|
1300
|
+
...(escalations.length === 0 ? {} : { escalations }),
|
|
1301
|
+
...(siblingTraffic.length === 0 ? {} : { siblingTraffic }),
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
catch (error) {
|
|
1305
|
+
// drain 抛错(如持久化失败):保留原始 message,不静默吞掉。
|
|
1306
|
+
tasks[item.index] = { taskId, status: "failed", reason: errorText(error), messageCount: 0 };
|
|
1307
|
+
}
|
|
1308
|
+
completed += 1;
|
|
1309
|
+
// D-044 §5.6:进度随任务完成节流上报(累计 step 数 + 最近一条回合摘要);
|
|
1310
|
+
// 只回摘要,不回模型正文。
|
|
1311
|
+
const progressSuffix = progressState.steps === 0
|
|
1312
|
+
? ""
|
|
1313
|
+
: ` · steps ${progressState.steps}${progressState.last === undefined ? "" : ` · ${progressState.last}`}`;
|
|
1314
|
+
onUpdate?.({
|
|
1315
|
+
content: [{ type: "text", text: `subagent ${completed}/${prompts.length}${progressSuffix}` }],
|
|
1316
|
+
details: {},
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
return { tasks: tasks.filter((task) => task !== undefined) };
|
|
1320
|
+
};
|
|
1321
|
+
/** `subagent_run` 的注册句柄:set/remove 后刷新替换,释放时以最新句柄为准。 */
|
|
1322
|
+
const runTool = { current: api.registerTool(buildRunToolDefinition()) };
|
|
1323
|
+
/** 同 owner 同名重注册 = 更新(旧句柄 dispose 因表项已被接手而失效)。 */
|
|
1324
|
+
const refreshRunTool = () => {
|
|
1325
|
+
runTool.current = api.registerTool(buildRunToolDefinition());
|
|
1326
|
+
};
|
|
1327
|
+
/**
|
|
1328
|
+
* 会话角色三工具(D-081):注册/列出/删除。注册是"先落盘再提交内存"——持久化条目
|
|
1329
|
+
* 写在会话文件里(customType `subagent.roles.state`),下次打开同一会话仍在;会话
|
|
1330
|
+
* 角色不写 `<agentDir>/roles/`、不跨会话共享、子代理不继承。三工具未声明 readonlySafe,
|
|
1331
|
+
* readonly 会话沿用既有"未声明即不激活"语义,不做特殊分支。
|
|
1332
|
+
*/
|
|
1333
|
+
const roleSetTool = api.registerTool({
|
|
1334
|
+
name: "subagent_role_set",
|
|
1335
|
+
label: "Register a session subagent role",
|
|
1336
|
+
description: "Register or update a session-scoped subagent role and use it by name in subagent_run. The role is persisted " +
|
|
1337
|
+
"in this session file, so it is still available when this session is opened again; it is not written to the " +
|
|
1338
|
+
"shared roles directory, not shared with other sessions, and not inherited by child subagents. Use it when the " +
|
|
1339
|
+
"built-in roles are too coarse for the task at hand. id, description and instructions are required; the optional " +
|
|
1340
|
+
"fields narrow the child the same way a role file does (tools/mode/model/thinkingLevel/systemPromptAppend) and " +
|
|
1341
|
+
"parentView adds a bounded parent projection. Same id updates that session role; the change is visible to the " +
|
|
1342
|
+
"next subagent_run call without restarting anything.",
|
|
1343
|
+
parameters: Type.Object({
|
|
1344
|
+
id: Type.String({
|
|
1345
|
+
pattern: "^[a-z0-9][a-z0-9_-]{0,31}$",
|
|
1346
|
+
description: "Lowercase role id (letters, digits, - and _, at most 32 chars)",
|
|
1347
|
+
}),
|
|
1348
|
+
description: Type.String({
|
|
1349
|
+
minLength: 1,
|
|
1350
|
+
maxLength: ROLE_DESCRIPTION_CHARS,
|
|
1351
|
+
description: `One-line role description shown in the subagent_run role menu (at most ${ROLE_DESCRIPTION_CHARS} chars)`,
|
|
1352
|
+
}),
|
|
1353
|
+
instructions: Type.String({
|
|
1354
|
+
minLength: 1,
|
|
1355
|
+
maxLength: ROLE_INSTRUCTIONS_CHARS,
|
|
1356
|
+
description: `Role instructions framing every task in a call (at most ${ROLE_INSTRUCTIONS_CHARS} chars)`,
|
|
1357
|
+
}),
|
|
1358
|
+
model: Type.Optional(Type.String({
|
|
1359
|
+
description: "Narrow the child to this model id (must resolve within this session's scoped models)",
|
|
1360
|
+
})),
|
|
1361
|
+
thinkingLevel: Type.Optional(Type.String({
|
|
1362
|
+
description: "Narrow the child thinking level: minimal | low | medium | high | xhigh | max",
|
|
1363
|
+
})),
|
|
1364
|
+
tools: Type.Optional(Type.Array(Type.String(), {
|
|
1365
|
+
description: "Tool allowlist intersected with the inherited tool set ([] gives the child no tools)",
|
|
1366
|
+
})),
|
|
1367
|
+
mode: Type.Optional(Type.Union([Type.Literal("readonly"), Type.Literal("full-control")], {
|
|
1368
|
+
description: "Narrow-only session mode: readonly never widens a readonly parent",
|
|
1369
|
+
})),
|
|
1370
|
+
systemPromptAppend: Type.Optional(Type.String({ description: "Text appended to the child system prompt (never replaces inherited content)" })),
|
|
1371
|
+
parentView: Type.Optional(Type.Boolean({
|
|
1372
|
+
description: "Give the child a bounded projection of the parent goal/tools (parent history stays hidden)",
|
|
1373
|
+
})),
|
|
1374
|
+
}),
|
|
1375
|
+
execute: async (first, second) => {
|
|
1376
|
+
const input = objectInput(first, second, "subagent_role_set");
|
|
1377
|
+
const id = input.id;
|
|
1378
|
+
if (typeof id !== "string" || !ROLE_ID_PATTERN.test(id)) {
|
|
1379
|
+
throw new SubagentDelegateError("subagent_invalid_input", `subagent_role_set id must match ${String(ROLE_ID_PATTERN)} (lowercase letters, digits, "-" and "_", ` +
|
|
1380
|
+
`at most 32 chars), got ${JSON.stringify(id)}`);
|
|
1381
|
+
}
|
|
1382
|
+
for (const key of Object.keys(input)) {
|
|
1383
|
+
if (key === "id" || ROLE_KNOWN_KEYS.includes(key))
|
|
1384
|
+
continue;
|
|
1385
|
+
throw new SubagentDelegateError("subagent_invalid_input", `subagent_role_set has unknown parameter ${JSON.stringify(key)} (known: id, ${ROLE_KNOWN_KEYS.join(", ")})`);
|
|
1386
|
+
}
|
|
1387
|
+
const definition = {};
|
|
1388
|
+
for (const key of ROLE_KNOWN_KEYS) {
|
|
1389
|
+
if (input[key] !== undefined)
|
|
1390
|
+
definition[key] = input[key];
|
|
1391
|
+
}
|
|
1392
|
+
let role;
|
|
1393
|
+
try {
|
|
1394
|
+
role = validateRoleDefinition(id, definition, `subagent_role_set "${id}"`);
|
|
1395
|
+
}
|
|
1396
|
+
catch (error) {
|
|
1397
|
+
throw new SubagentDelegateError("subagent_invalid_input", errorText(error));
|
|
1398
|
+
}
|
|
1399
|
+
if (role === undefined) {
|
|
1400
|
+
throw new SubagentDelegateError("subagent_invalid_input", `subagent_role_set could not build role ${JSON.stringify(id)}`);
|
|
1401
|
+
}
|
|
1402
|
+
// 磁盘角色是用户资产:与**当前可见**磁盘角色撞名的新注册结构化拒绝,不隐式改写
|
|
1403
|
+
// 其含义。已有会话数据条目(装载期被磁盘遮蔽的影子角色)走正常 upsert 路径:
|
|
1404
|
+
// 更新的是会话文件里的数据,视图仍由磁盘角色当值。
|
|
1405
|
+
if (diskRoles.has(id) && !sessionRoles.has(id)) {
|
|
1406
|
+
throw new SubagentDelegateError("subagent_role_conflict", `subagent role ${JSON.stringify(id)} is a disk role declared by this suite; a session role cannot shadow it — ` +
|
|
1407
|
+
"pick another id or edit the role file");
|
|
1408
|
+
}
|
|
1409
|
+
// 预算检查(暴露预算:可用集 + 新角色,磁盘+会话合并计算):超限结构化拒绝并提示精简。
|
|
1410
|
+
const prospective = new Map(roles);
|
|
1411
|
+
prospective.set(id, role);
|
|
1412
|
+
const quota = roleQuotaViolation(prospective);
|
|
1413
|
+
if (quota !== undefined)
|
|
1414
|
+
throw new SubagentDelegateError("subagent_role_quota", quota);
|
|
1415
|
+
// 写入顺序:先 appendEntry(失败原样抛出,注册即失败),成功后才提交内存——
|
|
1416
|
+
// 内存与文件不背离。快照是 append-only 全量形态,写的是**数据表**(含未暴露项,
|
|
1417
|
+
// 视图收窄不抹掉数据)。
|
|
1418
|
+
const status = sessionRoles.has(id) ? "updated" : "created";
|
|
1419
|
+
const nextSessionRoles = new Map(sessionRoles);
|
|
1420
|
+
nextSessionRoles.set(id, role);
|
|
1421
|
+
persistSessionRoles(nextSessionRoles.values());
|
|
1422
|
+
sessionRoles.set(id, role);
|
|
1423
|
+
// 视图提交:磁盘角色优先——影子 id 只更新数据,不改变可用集。
|
|
1424
|
+
if (!diskRoles.has(id))
|
|
1425
|
+
roles.set(id, role);
|
|
1426
|
+
refreshRunTool();
|
|
1427
|
+
return { id, status, roles: [...roles.keys()].sort() };
|
|
1428
|
+
},
|
|
1429
|
+
});
|
|
1430
|
+
const roleListTool = api.registerTool({
|
|
1431
|
+
name: "subagent_role_list",
|
|
1432
|
+
label: "List subagent roles",
|
|
1433
|
+
description: "List the subagent roles available in this session: id, origin (disk = shared role file, session = registered " +
|
|
1434
|
+
"here with subagent_role_set), description, and whether the role narrows the child (hasProfile) or enables the " +
|
|
1435
|
+
"bounded parent projection (hasParentView). Pass an id to get one role's full definition, including its " +
|
|
1436
|
+
"instructions and narrowing fields, to read, modify and re-register with subagent_role_set.",
|
|
1437
|
+
parameters: Type.Object({
|
|
1438
|
+
id: Type.Optional(Type.String({ description: "Role id; omit to list every available role" })),
|
|
1439
|
+
}),
|
|
1440
|
+
execute: async (first, second) => {
|
|
1441
|
+
const input = objectInput(first, second, "subagent_role_list");
|
|
1442
|
+
const requested = input.id;
|
|
1443
|
+
if (requested !== undefined && (typeof requested !== "string" || requested.trim() === "")) {
|
|
1444
|
+
throw new SubagentDelegateError("subagent_invalid_input", "subagent_role_list id must be a non-empty string when present");
|
|
1445
|
+
}
|
|
1446
|
+
const originOf = (roleId) => (diskRoles.has(roleId) ? "disk" : "session");
|
|
1447
|
+
if (requested === undefined) {
|
|
1448
|
+
const entries = [...roles.values()]
|
|
1449
|
+
.sort((left, right) => (left.id < right.id ? -1 : 1))
|
|
1450
|
+
.map((role) => ({
|
|
1451
|
+
id: role.id,
|
|
1452
|
+
origin: originOf(role.id),
|
|
1453
|
+
description: role.description,
|
|
1454
|
+
hasProfile: hasNarrowingProfile(role),
|
|
1455
|
+
hasParentView: role.parentView === true,
|
|
1456
|
+
}));
|
|
1457
|
+
return { roles: entries };
|
|
1458
|
+
}
|
|
1459
|
+
const role = roles.get(requested);
|
|
1460
|
+
if (role === undefined) {
|
|
1461
|
+
throw new SubagentDelegateError("subagent_unknown_role", unknownRoleMessage(requested, roles));
|
|
1462
|
+
}
|
|
1463
|
+
return { role: { ...toSessionRoleRecord(role), origin: originOf(role.id) } };
|
|
1464
|
+
},
|
|
1465
|
+
});
|
|
1466
|
+
const roleRemoveTool = api.registerTool({
|
|
1467
|
+
name: "subagent_role_remove",
|
|
1468
|
+
label: "Remove a session subagent role",
|
|
1469
|
+
description: "Remove one session-scoped subagent role (one registered with subagent_role_set) from this session. The removal " +
|
|
1470
|
+
"is persisted in the session file and takes effect for the next subagent_run call. Disk roles cannot be removed " +
|
|
1471
|
+
"here — they live in the shared roles directory and are changed by editing their file; such a call is rejected " +
|
|
1472
|
+
"with a structured error.",
|
|
1473
|
+
parameters: Type.Object({
|
|
1474
|
+
id: Type.String({ description: "Session role id to remove" }),
|
|
1475
|
+
}),
|
|
1476
|
+
execute: async (first, second) => {
|
|
1477
|
+
const input = objectInput(first, second, "subagent_role_remove");
|
|
1478
|
+
const id = input.id;
|
|
1479
|
+
if (typeof id !== "string" || id.trim() === "") {
|
|
1480
|
+
throw new SubagentDelegateError("subagent_invalid_input", "subagent_role_remove requires a role id");
|
|
1481
|
+
}
|
|
1482
|
+
// 会话数据条目优先:被磁盘遮蔽的影子角色也能删(删的是会话文件里的数据,
|
|
1483
|
+
// 磁盘角色本身不受影响);没有会话数据时才按"纯磁盘角色"结构化拒绝。
|
|
1484
|
+
if (!sessionRoles.has(id)) {
|
|
1485
|
+
if (diskRoles.has(id)) {
|
|
1486
|
+
throw new SubagentDelegateError("subagent_role_conflict", `subagent role ${JSON.stringify(id)} is a disk role; disk roles are changed by editing their file, ` +
|
|
1487
|
+
"not removed from a session");
|
|
1126
1488
|
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1489
|
+
throw new SubagentDelegateError("subagent_unknown_role", unknownRoleMessage(id, roles));
|
|
1490
|
+
}
|
|
1491
|
+
// 同样的写入顺序:先全量快照落盘,成功后才提交内存与刷新工具面。
|
|
1492
|
+
const nextSessionRoles = new Map(sessionRoles);
|
|
1493
|
+
nextSessionRoles.delete(id);
|
|
1494
|
+
persistSessionRoles(nextSessionRoles.values());
|
|
1495
|
+
sessionRoles.delete(id);
|
|
1496
|
+
// 视图提交:磁盘角色优先——删掉影子数据后磁盘角色仍是可用集的当值项。
|
|
1497
|
+
if (!diskRoles.has(id))
|
|
1498
|
+
roles.delete(id);
|
|
1499
|
+
refreshRunTool();
|
|
1500
|
+
return { id, status: "removed", roles: [...roles.keys()].sort() };
|
|
1501
|
+
},
|
|
1502
|
+
});
|
|
1503
|
+
/**
|
|
1504
|
+
* `/roles` 视图(D-081 用户面):数据源只有上面的三个闭包表(实时值)——可用集
|
|
1505
|
+
* `roles`(按来源分区,磁盘 `diskRoles` 优先)、数据表 `sessionRoles`(诊断里
|
|
1506
|
+
* "数据仍在会话文件里"的判据)、水合诊断 `hydratedRoles` 与目录解析诊断
|
|
1507
|
+
* `roleCatalog.dangling`。分区/诊断行仅在非空时出现,列表按 id 排序。
|
|
1508
|
+
*/
|
|
1509
|
+
const renderRolesOverview = () => {
|
|
1510
|
+
// roles.agentDir 是角色目录(<agentDir>/roles/)的来源;没有它就没有磁盘角色面,
|
|
1511
|
+
// 路径提示也就无从谈起(未见关联的会话不引导用户去丢文件)。
|
|
1512
|
+
const roleAgentDir = options?.roles?.agentDir;
|
|
1513
|
+
const sorted = [...roles.values()].sort((left, right) => (left.id < right.id ? -1 : 1));
|
|
1514
|
+
const disk = sorted.filter((role) => diskRoles.has(role.id));
|
|
1515
|
+
const session = sorted.filter((role) => !diskRoles.has(role.id));
|
|
1516
|
+
const lines = [];
|
|
1517
|
+
if (sorted.length === 0) {
|
|
1518
|
+
lines.push("No subagent roles are available in this session. Register one with subagent_role_set" +
|
|
1519
|
+
(roleAgentDir === undefined
|
|
1131
1520
|
? ""
|
|
1132
|
-
:
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1521
|
+
: `, or drop a role file into ${join(roleAgentDir, ROLE_CATALOG_DIR)}/ and associate its id in the suite.`));
|
|
1522
|
+
}
|
|
1523
|
+
else {
|
|
1524
|
+
lines.push(`Subagent roles available in this session: ${sorted.length}`);
|
|
1525
|
+
if (disk.length > 0 || session.length > 0)
|
|
1526
|
+
lines.push("");
|
|
1527
|
+
if (disk.length > 0) {
|
|
1528
|
+
lines.push("disk:");
|
|
1529
|
+
for (const role of disk)
|
|
1530
|
+
lines.push(` ${role.id} — ${role.description}`);
|
|
1531
|
+
}
|
|
1532
|
+
if (session.length > 0) {
|
|
1533
|
+
lines.push("session:");
|
|
1534
|
+
for (const role of session)
|
|
1535
|
+
lines.push(` ${role.id} — ${role.description}`);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
// 诊断区(仅非空时显示):影子/超预算条目按数据表过滤——已被 remove 的旧诊断
|
|
1539
|
+
// 不再声称"数据仍保留";悬空关联只列 id,有角色目录时附逐 id 修复提示。
|
|
1540
|
+
const shadowed = [...hydratedRoles.shadowedByDisk].filter((id) => sessionRoles.has(id)).sort();
|
|
1541
|
+
const overBudget = [...hydratedRoles.droppedForQuota].filter((id) => sessionRoles.has(id)).sort();
|
|
1542
|
+
const dangling = [...roleCatalog.dangling].sort();
|
|
1543
|
+
if (shadowed.length > 0 || overBudget.length > 0) {
|
|
1544
|
+
lines.push("", "Not exposed (data kept in this session file):");
|
|
1545
|
+
if (shadowed.length > 0)
|
|
1546
|
+
lines.push(` shadowed by a visible disk role: ${shadowed.join(", ")}`);
|
|
1547
|
+
if (overBudget.length > 0) {
|
|
1548
|
+
lines.push(` over the menu budget (${ROLE_COUNT_LIMIT} roles / ${ROLE_MENU_CHARS} chars): ${overBudget.join(", ")}`);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
if (dangling.length > 0) {
|
|
1552
|
+
lines.push("", `Suite associations without a role file (unavailable): ${dangling.join(", ")}`);
|
|
1553
|
+
if (roleAgentDir !== undefined) {
|
|
1554
|
+
for (const id of dangling) {
|
|
1555
|
+
lines.push(` create ${subagentRoleCatalogPath(roleAgentDir, id)} or drop the association`);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return lines.join("\n");
|
|
1560
|
+
};
|
|
1561
|
+
/** `/roles <id>` 视图:单角色完整定义(收窄字段存在才显示,parentView 仅 true 显示)。 */
|
|
1562
|
+
const renderRoleDefinitionView = (role) => {
|
|
1563
|
+
const lines = [
|
|
1564
|
+
`id: ${role.id}`,
|
|
1565
|
+
`origin: ${diskRoles.has(role.id) ? "disk" : "session"}`,
|
|
1566
|
+
`description: ${role.description}`,
|
|
1567
|
+
"instructions:",
|
|
1568
|
+
role.instructions,
|
|
1569
|
+
];
|
|
1570
|
+
const profile = role.profile;
|
|
1571
|
+
if (profile?.model !== undefined)
|
|
1572
|
+
lines.push(`model: ${profile.model}`);
|
|
1573
|
+
if (profile?.thinkingLevel !== undefined)
|
|
1574
|
+
lines.push(`thinkingLevel: ${profile.thinkingLevel}`);
|
|
1575
|
+
if (profile?.tools !== undefined)
|
|
1576
|
+
lines.push(`tools: ${profile.tools.join(", ")}`);
|
|
1577
|
+
if (profile?.mode !== undefined)
|
|
1578
|
+
lines.push(`mode: ${profile.mode}`);
|
|
1579
|
+
if (profile?.systemPromptAppend !== undefined)
|
|
1580
|
+
lines.push("systemPromptAppend:", profile.systemPromptAppend);
|
|
1581
|
+
if (role.parentView === true)
|
|
1582
|
+
lines.push("parentView: true");
|
|
1583
|
+
return lines.join("\n");
|
|
1584
|
+
};
|
|
1585
|
+
/**
|
|
1586
|
+
* `/roles` 斜杠命令(D-081 用户面):与角色三工具同一注册分支(委派面存在才注册),
|
|
1587
|
+
* 句柄随插件 dispose。只读视图——不改状态、不引入新配置键;未知 id 是正常结果
|
|
1588
|
+
* (error + 可用 id 列表),任何异常都收敛为 `{ status: "error" }`,命令不崩宿主。
|
|
1589
|
+
*/
|
|
1590
|
+
const rolesCommand = api.registerCommand({
|
|
1591
|
+
name: "roles",
|
|
1592
|
+
description: "List the subagent roles available in this session (disk + session), or show one role's full definition",
|
|
1593
|
+
argumentHint: "[id]",
|
|
1594
|
+
execute: async (args) => {
|
|
1595
|
+
try {
|
|
1596
|
+
const requested = args.trim();
|
|
1597
|
+
if (requested === "") {
|
|
1598
|
+
return { status: "success", message: renderRolesOverview() };
|
|
1599
|
+
}
|
|
1600
|
+
const role = roles.get(requested);
|
|
1601
|
+
if (role === undefined) {
|
|
1602
|
+
return { status: "error", message: unknownRoleMessage(requested, roles) };
|
|
1603
|
+
}
|
|
1604
|
+
return { status: "success", message: renderRoleDefinitionView(role) };
|
|
1605
|
+
}
|
|
1606
|
+
catch (error) {
|
|
1607
|
+
// A slash command must not crash the host (memory /promote precedent).
|
|
1608
|
+
return { status: "error", message: errorText(error) };
|
|
1137
1609
|
}
|
|
1138
|
-
return { tasks: tasks.filter((task) => task !== undefined) };
|
|
1139
1610
|
},
|
|
1140
1611
|
});
|
|
1141
1612
|
/**
|
|
@@ -1416,11 +1887,16 @@ export function createCapability(api, options) {
|
|
|
1416
1887
|
},
|
|
1417
1888
|
});
|
|
1418
1889
|
return [
|
|
1419
|
-
|
|
1890
|
+
// 刷新句柄:释放时以最新一次 subagent_run 注册为准(旧句柄 dispose 因表项被接手而失效)。
|
|
1891
|
+
createDisposableRegistration("subagent-delegate.tool.subagent_run", () => void runTool.current.dispose(), `registration:${capabilityManifest.id}:refreshable:tool:subagent_run`),
|
|
1420
1892
|
resultTool,
|
|
1421
1893
|
cancelTool,
|
|
1422
1894
|
steerTool,
|
|
1423
1895
|
resumeTool,
|
|
1896
|
+
roleSetTool,
|
|
1897
|
+
roleListTool,
|
|
1898
|
+
roleRemoveTool,
|
|
1899
|
+
rolesCommand,
|
|
1424
1900
|
...(listTool === undefined ? [] : [listTool]),
|
|
1425
1901
|
// 子侧通信面(D-060 S4/S5):获准自建会话两面并存(向下委派 + 向父上抛/兄弟消息)。
|
|
1426
1902
|
...childTools,
|