@aalis/plugin-subtask 0.13.1 → 0.15.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/dist/index.js CHANGED
@@ -1,9 +1,19 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { agent } from '@aalis/api-agent'; // 也是本包 declaration merging 的激活点(agent:* 钩子)
3
+ import { gateway } from '@aalis/api-gateway';
1
4
  import { hooks } from '@aalis/api-hooks';
5
+ import { ownHostConfig } from '@aalis/api-host-config';
6
+ import { MODEL_REF_TEXT, modelRefOf } from '@aalis/api-llm';
7
+ import { historyCleared } from '@aalis/api-memory'; // 也是 declaration merging 的激活点(memory:clear 钩子)
2
8
  import { messageArchive } from '@aalis/api-message-archive';
3
- import { sessionManager } from '@aalis/api-session-manager';
4
- import { tools } from '@aalis/api-tools';
9
+ import { sessionLabel, sessionManager } from '@aalis/api-session-manager';
10
+ import { sessionSettings } from '@aalis/api-session-settings';
11
+ import { actingIdentity, backgroundTasks, tools } from '@aalis/api-tools';
5
12
  import { config, definePlugin, events, logger, optional } from '@aalis/core';
6
13
  import { defineConfig, parseConfig } from '@aalis/schema-config';
14
+ import { toSource } from '@aalis/schema-i18n';
15
+ import { WellKnownMetadataKeys } from '@aalis/schema-message';
16
+ import { t } from './texts.js';
7
17
  // =====================================================================
8
18
  // plugin-subtask —— 派发并行子会话(独立 sessionId 的子 agent)
9
19
  //
@@ -13,31 +23,123 @@ import { defineConfig, parseConfig } from '@aalis/schema-config';
13
23
  // - plugin-subtask :跨会话的并行子 agent,适合「拆分独立调研任务」
14
24
  // - plugin-skills :可加载的提示词模板,正交,可在子任务里继续使用
15
25
  //
16
- // 推荐协作模式:
17
- // 1. 父会话 manage_todo_list 列出大步骤
18
- // 2. 对独立可并行的子任务 create_subtask × N(可指定轻量 model 省 token)
19
- // 3. wait_subtasks 阻塞等待
20
- // 4. 父会话整合 → manage_todo_list 标记完成
21
- // 子会话内可自行再用 manage_todo_list / load_skill / workflow_run 等工具。
26
+ // 推荐用法见 create_subtask 的工具说明。
27
+ //
28
+ // 子会话在建立时写下父会话此刻的设置(会话设置管理面的 childMetadata),之后独立;父会话经 set_subtask_setting 改直接子会话
29
+ // 本会话层的设置。子会话的一轮结束后闲置满 idleDeleteHours 即销毁,销毁之前给父会话写回报。
22
30
  // =====================================================================
31
+ /**
32
+ * 派发与追问消息的来源:它们是内部注入,不是真人当面发的话。指令的意图确认只认同一会话里不带 source 的下一条消息,
33
+ * 不带 source 时父会话经 send_to_subtask 发来的 y 会被当成发出者本人的确认(userId 同为 `parent:<父会话>`),
34
+ * 父会话里的模型就能替子任务里的指令确认;要求真人当面发起的工具(白纸交任务等)也会放行子任务回合。
35
+ * 派发与追问同一个值,各自成一轮:追问排在子任务在跑的那一轮之后。
36
+ */
37
+ const SUBTASK_SOURCE = 'subtask';
38
+ /**
39
+ * 发给子会话的任务消息(派发与追问同一个写法)。userId 是归档用的物理来源标记(authority 查不到,等价匿名),身份:
40
+ * - 调用在入站回合里:带着这次调用所在那一轮(fromTurn:平台、发言者、这次调用的参与者(批过的是批的人)、来源与那一轮
41
+ * 自己的 fromTurn),不填 actor;子任务那一轮的参与者取它的,权限服务按它判子任务这一轮的主体,不按父会话里别的哪一轮;
42
+ * - 不在入站回合里(编排的工具节点、mcp-server):不带 fromTurn,授权身份走 actor(这次调用的唯一参与者;匿名或几个
43
+ * 参与者时不填,子任务同样匿名)。
44
+ * 追问那一轮按发追问那次调用的参与者,同一子任务会话各轮的身份可以不同;send_to_subtask 与 create_subtask
45
+ * 同为 2 级加确认。
46
+ */
47
+ function taskMessage(callCtx, sessionId, content) {
48
+ const { platform, userId, participants, inbound } = callCtx;
49
+ const message = {
50
+ content,
51
+ sessionId,
52
+ platform: platform || 'internal',
53
+ userId: `parent:${callCtx.sessionId}`,
54
+ source: SUBTASK_SOURCE,
55
+ };
56
+ if (inbound) {
57
+ message.fromTurn = {
58
+ platform: message.platform,
59
+ userId,
60
+ participants,
61
+ source: inbound.source,
62
+ fromTurn: inbound.fromTurn,
63
+ };
64
+ return message;
65
+ }
66
+ const identity = actingIdentity(callCtx);
67
+ if (identity.userId)
68
+ message.actor = identity;
69
+ return message;
70
+ }
23
71
  const configSchema = defineConfig({
24
- enabled: { type: 'boolean', label: '启用子任务工具', default: true, onInvalid: 'error' },
25
- maxWaitMs: { type: 'number', label: '单次等待最大时长 (ms)', default: 300000 },
26
- defaultProvider: {
27
- type: 'string',
28
- label: '子任务默认 LLM provider',
29
- default: '',
30
- onInvalid: 'error',
31
- description: '不填则继承父会话。建议填轻量本地模型(如 ollama)让子任务跑在更便宜的模型上,节省 token',
32
- },
33
- defaultModel: {
34
- type: 'string',
35
- label: '子任务默认模型名',
36
- default: '',
37
- onInvalid: 'error',
38
- description: '与 defaultProvider 配套。例如 qwen3:8b、deepseek-chat。两个都不填则继承父会话模型。',
72
+ enabled: { type: 'boolean', label: t('enabledLabel'), default: true, onInvalid: 'error' },
73
+ maxWaitMs: { type: 'number', label: t('maxWaitMsLabel'), default: 300000 },
74
+ // 按会话字段:按派子任务的会话解析,能按群设;1 级可改
75
+ model: {
76
+ type: 'llm-ref',
77
+ label: t('modelLabel'),
78
+ description: t('modelDescription'),
79
+ ...MODEL_REF_TEXT,
80
+ session: { appliesTo: ['owner', 'room'], prominence: 'advanced', unset: t('modelUnset'), minLevel: 1 },
39
81
  },
82
+ idleDeleteHours: { type: 'number', label: t('idleLabel'), description: t('idleDescription'), default: 24, min: 0 },
40
83
  });
84
+ const HOUR_MS = 3_600_000;
85
+ /** 到期定时器的最长延时:超过 2^31−1 毫秒的延时 Node 按 1 毫秒触发,截到一天、到点重判 */
86
+ const MAX_TIMER_MS = 24 * HOUR_MS;
87
+ /** 交给模型与日志的子任务名:没取名的是默认名的原文 */
88
+ const nameOf = (session) => toSource(sessionLabel(session));
89
+ /**
90
+ * 出错的子任务的出错原因(session-manager 记在 lastTurn,交给模型取原文),其余状态不带:为 undefined 的键序列化后不出现在
91
+ * 工具结果里
92
+ */
93
+ function errorReason(session, status) {
94
+ return status === 'error' ? toSource(session.lastTurn?.error) : undefined;
95
+ }
96
+ const STATUS_LABELS = {
97
+ active: '进行中',
98
+ completed: '已完成',
99
+ error: '出错',
100
+ stopped: '已停止',
101
+ };
102
+ const NOT_FOUND = '子任务不存在';
103
+ /** 父会话记录 metadata 里本插件的键:到期销毁的子任务留下的回报 */
104
+ const REPORTS_KEY = 'subtask';
105
+ /** 一个会话记录上的回报 */
106
+ function reportsOf(session) {
107
+ const reports = session?.metadata?.[REPORTS_KEY]?.reports;
108
+ return Array.isArray(reports)
109
+ ? reports.filter((r) => typeof r?.id === 'string')
110
+ : [];
111
+ }
112
+ /** 子任务此刻的回报:不写没有的项 */
113
+ function reportOf(session, status) {
114
+ const reason = errorReason(session, status);
115
+ return {
116
+ id: session.id,
117
+ name: nameOf(session),
118
+ status,
119
+ ...(reason === undefined ? {} : { reason }),
120
+ ...(session.result ? { result: session.result } : {}),
121
+ ...(session.inputContext ? { inputContext: session.inputContext } : {}),
122
+ };
123
+ }
124
+ function writeReports(sm, sessionId, reports) {
125
+ return sm.updateSession(sessionId, { metadata: { [REPORTS_KEY]: { reports } } });
126
+ }
127
+ /** 取走调用所在会话记录上某个子任务的回报(读到即删);只在调用方自己的记录上找,别的会话的子任务取不到 */
128
+ async function takeReport(sm, sessionId, id) {
129
+ const reports = reportsOf(sm.getSession(sessionId));
130
+ const report = reports.find(r => r.id === id);
131
+ if (report)
132
+ await writeReports(sm, sessionId, reports.filter(r => r !== report));
133
+ return report;
134
+ }
135
+ /**
136
+ * 按 id 取调用所在会话的直接子任务,五个按 id 操作的工具都经这里:别的会话的子任务、根会话与不存在的一样取不到,
137
+ * 一律按 {@link NOT_FOUND} 回,不确认存在与否,也不交出、不等、不改它们
138
+ */
139
+ function directChild(sm, id, sessionId) {
140
+ const session = sm.getSession(id);
141
+ return session?.parentId === sessionId ? session : undefined;
142
+ }
41
143
  // ===== 插件入口 =====
42
144
  const uses = {
43
145
  tools: optional(tools),
@@ -47,39 +149,119 @@ const uses = {
47
149
  config,
48
150
  sessionManager: optional(sessionManager),
49
151
  messageArchive: optional(messageArchive),
152
+ /**
153
+ * 给子会话写子任务模型(assignModel);工具参数指定的模型按它的实例 id 认「模型」这一项。不在时要指定模型的子任务不建
154
+ */
155
+ agent: optional(agent),
156
+ /**
157
+ * 会话设置的管理面:建子会话之前取 childMetadata、按调用者的身份写子会话的设置(setAs:工具参数指定的模型、父会话改子会话的
158
+ * 设置)、读本插件的子任务模型。不在时不建子会话:子会话没法写下父会话此刻的设置
159
+ */
160
+ sessionSettings: optional(sessionSettings),
161
+ /** 追问经它送达(落定时知道);不在时追问发不出 */
162
+ gateway: optional(gateway),
163
+ /** 按会话字段的全局层:宿主配置文档里本实例的那一段 */
164
+ document: optional(ownHostConfig),
165
+ /** 子会话在跑的后台任务:有的不到期销毁;不在时没有 */
166
+ backgroundTasks: optional(backgroundTasks),
50
167
  };
51
168
  export default definePlugin({
52
169
  name: '@aalis/plugin-subtask',
53
- displayName: '子任务',
170
+ displayName: t('displayName'),
54
171
  subsystem: 'session',
55
172
  configSchema,
56
173
  uses,
57
174
  apply(caps) {
58
- const parsed = parseConfig(configSchema, caps.config, caps.logger);
175
+ const parsed = parseConfig(configSchema, caps.config.use, caps.logger.use);
176
+ if (!parsed.enabled)
177
+ return;
59
178
  const cfg = {
60
- ...parsed,
61
179
  maxWaitMs: parsed.maxWaitMs || configSchema.maxWaitMs.default,
62
- defaultProvider: parsed.defaultProvider.trim(),
63
- defaultModel: parsed.defaultModel.trim(),
180
+ idleDeleteHours: parsed.idleDeleteHours,
64
181
  };
65
- if (!cfg.enabled)
66
- return;
67
- registerSubtask(caps, cfg);
182
+ registerSubtask(caps, cfg, caps.sessionSettings.use.fields(configSchema, caps));
68
183
  },
69
184
  });
70
- function registerSubtask({ tools, events, hooks, logger, sessionManager, messageArchive }, cfg) {
185
+ function registerSubtask({ tools, events, hooks, logger, sessionManager, messageArchive, agent, sessionSettings, gateway, backgroundTasks, }, cfg, fields) {
186
+ // ---- 到期销毁的记录(见文末「到期销毁」)----
187
+ /** 在飞的回合数 */
188
+ const inflight = new Map();
189
+ /** 发出了还没落定的追问(所在的一轮结束、被放掉、在网关相位被挡下都算落定)的条数 */
190
+ const followUps = new Map();
191
+ /**
192
+ * 追问开的那一轮跑在送达它的调用的异步上下文里,回合结束时据此认出这一轮是不是追问开的。每条追问一个令牌,落定时从 live 里删:
193
+ * 那一轮里起的异步工作(转后台的工具、定时器)继承同一个上下文,之后它们开的一轮拿到的是已落定的令牌,不算追问开的
194
+ */
195
+ const followUpTurn = new AsyncLocalStorage();
196
+ const live = new Set();
197
+ /** 有追问落定时通知的:wait_subtasks 据此重判 */
198
+ const onFollowUpSettled = new Set();
199
+ /** 一条追问落定:没有别的了就重排到期(到点时它还没落定的那次判定没有重排) */
200
+ function followUpSettled(childId) {
201
+ const rest = (followUps.get(childId) ?? 1) - 1;
202
+ if (rest > 0)
203
+ followUps.set(childId, rest);
204
+ else {
205
+ followUps.delete(childId);
206
+ if (!inflight.has(childId))
207
+ schedule(childId);
208
+ }
209
+ for (const listener of onFollowUpSettled)
210
+ listener();
211
+ }
212
+ /**
213
+ * 子任务此刻的状态:还有没落定的追问时按进行中(会话管理在它前后两轮之间可能已把状态收口成已完成);最后一轮被停止、收口为
214
+ * completed 的报成 stopped
215
+ */
216
+ const statusOf = (session) => followUps.has(session.id)
217
+ ? 'active'
218
+ : session.status === 'completed' && session.lastTurn?.outcome === 'aborted'
219
+ ? 'stopped'
220
+ : session.status;
221
+ /** 本插件记下的一轮结束时刻;重启或重载之后没有,取会话记录上的 lastTurn.endedAt */
222
+ const endedAt = new Map();
223
+ const timers = new Map();
224
+ /** 父会话这一轮的提醒里列出过的回报(子会话 id):这一轮回复或静默收尾时删 */
225
+ const listed = new Map();
226
+ /** 一轮的结局(回合 id → 结局):agent:turn:after 记下,同一轮的 agent:turn:before 收尾时取走 */
227
+ const outcomes = new Map();
228
+ /**
229
+ * 子会话还在时 check_subtask、wait_subtasks 交出过的结果(子会话 id → 那一份回报):到期销毁时同一份不再写回报。只记在内存里,
230
+ * 重启或重载之后到期的照样写回报
231
+ */
232
+ const taken = new Map();
233
+ /**
234
+ * 查一个子任务(check_subtask、wait_subtasks):还在的直接子任务取此刻的回报,结束了的记下交出过这一份,并取走到期销毁这一拍里
235
+ * 已写进调用所在会话记录的同一条回报;已销毁的取走调用所在会话记录上的回报
236
+ */
237
+ async function lookup(sm, id, sessionId) {
238
+ const session = directChild(sm, id, sessionId);
239
+ if (session) {
240
+ const report = reportOf(session, statusOf(session));
241
+ if (report.status !== 'active') {
242
+ taken.set(id, JSON.stringify(report));
243
+ await takeReport(sm, sessionId, id);
244
+ }
245
+ return { report, destroyed: false };
246
+ }
247
+ const report = await takeReport(sm, sessionId, id);
248
+ return report && { report, destroyed: true };
249
+ }
250
+ /** 跟随中的会话管理:定时器只在它在场时排 */
251
+ let tracked;
71
252
  // 注册工具分组
72
- tools.registerGroup({
253
+ tools.use.registerGroup({
73
254
  name: 'subtask',
74
- label: '子任务管理',
255
+ label: t('groupLabel'),
256
+ summary: t('groupSummary'),
75
257
  description: '创建、管理和协调子任务会话,支持并行执行',
76
258
  });
77
259
  // ---- create_subtask ----
78
- tools.register({
260
+ tools.use.register({
79
261
  groups: ['subtask'],
80
- // 每个子任务是一条独立的 LLM 会话链——不受信任的调用方可连续创建以放大 API 开销。
81
- // sensitive(L1) 挡住 level-0,不加逐次 confirm(正常用法是 agent 连续创建多个并行子任务)。
82
- risk: 'sensitive',
262
+ // 子任务替发起的人在后台接着做(每个是一条独立的 LLM 会话链):建、追问、改设置与删都与编排、定时任务、停后台任务同一规则,
263
+ // 2 级加确认。别人单独发起的没人能批即拒;owner 一个人的回合在免确认白名单里不问,常连续派几个并行子任务的把它加进白名单
264
+ risk: 'dangerous',
83
265
  definition: {
84
266
  type: 'function',
85
267
  function: {
@@ -90,16 +272,16 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
90
272
  '你可以连续调用多次 create_subtask 来创建多个并行子任务。',
91
273
  '',
92
274
  '【重要协作规范】',
93
- '1. 创建子任务前,先用 manage_todo_list 列出计划,包含"创建子任务"和"等待子任务完成"两步',
94
- '2. 共享文档协作:如果多个子任务需要操作同一个文档(如 PPT、Word、Excel),先在主会话中创建文档拿到 docId,然后在 task 中明确告知子任务该 docId——子任务会直接操作同一文档,无需各自创建',
95
- '3. task 描述中应包含:(a) 具体任务内容 (b) 需要使用的共享资源 ID(如 docId)(c) 子任务负责的范围(如"负责第3-4张幻灯片")',
96
- '4. 创建完所有子任务后,必须立即调用 wait_subtasks 等待结果,不要在未等待的情况下继续主任务',
97
- '5. 子任务无法调用 create_subtask(不可嵌套);保持 save 操作在主会话中执行',
275
+ '1. task 描述中应写清:(a) 具体任务内容 (b) 要用到的共享资源 (c) 子任务负责的范围',
276
+ '2. 创建完所有子任务后,必须立即调用 wait_subtasks 等待结果,不要在未等待的情况下继续主任务',
277
+ '3. 子任务无法调用 create_subtask(不可嵌套)',
98
278
  '',
99
- '【模型选择】可选 provider+model 参数为子任务指定更便宜/轻量的模型:',
279
+ '【模型选择】可选 model 参数为子任务指定更便宜/轻量的模型,写条目 id「<提供者实例 id>/<模型 id>」,与 /model 列出的写法相同:',
100
280
  '- 适合轻量模型的任务:语法检查、格式转换、摄取信息、翻译、简单总结、文档快速审阅',
101
281
  '- 依然需要父同模型的任务:复杂推理、多步规划、代码生成、独立决策',
102
- '- 不填则使用插件默认子任务模型,都未配置则继承父会话',
282
+ '- 不填则用本会话的子任务模型,没设时与本会话的模型相同',
283
+ '',
284
+ '子任务交回结果(一轮结束,不论回复、出错还是被停止)后闲置一段时间没人追问就会销毁,销毁后要重新创建。',
103
285
  ].join('\n'),
104
286
  parameters: {
105
287
  type: 'object',
@@ -112,13 +294,9 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
112
294
  type: 'string',
113
295
  description: '子任务显示名称(可选,默认自动生成)',
114
296
  },
115
- provider: {
116
- type: 'string',
117
- description: '可选:为子任务指定 LLM provider(如 ollama 、 deepseek)。与 model 配套使用,二者同填才会生效。',
118
- },
119
297
  model: {
120
298
  type: 'string',
121
- description: '可选:为子任务指定 model 名称(如 qwen3:8b、deepseek-chat)。二者同填才会生效;未填则使用插件默认或父会话模型。',
299
+ description: '可选:为子任务指定模型,写条目 id「<提供者实例 id>/<模型 id>」(模型 id 里可以有斜杠);不填则用本会话的子任务模型,没设时与本会话的模型相同。',
122
300
  },
123
301
  },
124
302
  required: ['task'],
@@ -127,9 +305,9 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
127
305
  },
128
306
  },
129
307
  handler: async (args, callCtx) => {
130
- const sm = sessionManager.current;
131
- if (!sm)
308
+ if (!sessionManager.present)
132
309
  return JSON.stringify({ error: 'session-manager 服务不可用' });
310
+ const sm = sessionManager.use;
133
311
  const task = String(args.task || '');
134
312
  if (!task)
135
313
  return JSON.stringify({ error: '缺少任务描述' });
@@ -139,55 +317,61 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
139
317
  if (parentSession?.parentId) {
140
318
  return JSON.stringify({ error: '子任务中不能再创建子任务。请直接完成当前任务,由父会话负责任务拆分和协调。' });
141
319
  }
142
- const taskName = args.name
143
- ? String(args.name)
144
- : `子任务 ${new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
320
+ // 子会话在建立时写下本会话此刻的设置:会话设置不在时写不下,照常建的话本会话那一层的收窄就丢了
321
+ if (!sessionSettings.present) {
322
+ return JSON.stringify({ error: '会话设置不在,不能创建子任务:子会话要在创建时写下本会话此刻的设置' });
323
+ }
324
+ // 子会话的对话模型:工具参数 › 本会话此刻生效的子任务模型 › 随设置写下的本会话此刻的模型(不另指定)
325
+ const text = String(args.model ?? '').trim();
326
+ const argModel = text ? modelRefOf(text) : undefined;
327
+ if (text && !argModel) {
328
+ return JSON.stringify({
329
+ error: `model 写法不对:${text}。写成「<提供者实例 id>/<模型 id>」,与 /model 列出的相同`,
330
+ });
331
+ }
332
+ const model = argModel ?? fields.get(parentId, { platform: callCtx.platform }).model;
333
+ if (model && !agent.present) {
334
+ return JSON.stringify({ error: '要给子任务指定模型,但 agent 服务不在,不能创建子任务' });
335
+ }
336
+ // 没给名字的不起名:显示名由读者按建档时间给(sessionLabel)
337
+ const taskName = args.name ? String(args.name) : undefined;
145
338
  try {
146
- // 子会话复制父会话的 resolved config
147
- const inheritedConfig = sm.resolveConfig(parentId, callCtx.platform);
148
- // 模型优先级:工具参数 > 插件默认 > 父会话继承
149
- // 仅当 provider 和 model 同时提供才覆盖,避免半填产生歧义
150
- const argProvider = String(args.provider ?? '').trim();
151
- const argModel = String(args.model ?? '').trim();
152
- let llmOverride;
153
- if (argProvider && argModel) {
154
- llmOverride = { provider: argProvider, model: argModel };
155
- }
156
- else if (cfg.defaultProvider && cfg.defaultModel) {
157
- llmOverride = { provider: cfg.defaultProvider, model: cfg.defaultModel };
158
- }
159
- const childConfig = llmOverride ? { ...inheritedConfig, llm: llmOverride } : { ...inheritedConfig };
160
- // 创建子会话(存储 platform 用于后续自动触发)
161
339
  const child = await sm.createChildSession(parentId, {
162
340
  name: taskName,
163
- createdBy: 'agent',
341
+ title: taskName,
164
342
  inputContext: task,
165
- config: childConfig,
166
- metadata: { platform: callCtx.platform || 'internal', title: taskName },
343
+ metadata: sessionSettings.use.childMetadata(parentId, { platform: callCtx.platform }),
167
344
  });
345
+ if (model) {
346
+ try {
347
+ // 工具参数按这次调用的授权身份写,与 set_subtask_setting 同一条路:agent「模型」这一项与本会话层的门槛都判,owner 在
348
+ // 操作门槛里调高的照算。子任务模型是能改它的人设下的,由 agent 直接写
349
+ if (argModel) {
350
+ const agentId = agent.list().find(entry => entry.selected)?.id;
351
+ const receipt = await sessionSettings.use.setAs(actingIdentity(callCtx), child.id, `${agentId}/llm`, text);
352
+ if (!receipt.ok)
353
+ throw new Error(toSource(receipt.text));
354
+ }
355
+ else
356
+ await agent.use.assignModel(child.id, model);
357
+ }
358
+ catch (err) {
359
+ await sm.deleteSession(child.id);
360
+ throw new Error(`给子会话指定模型失败(${err instanceof Error ? err.message : String(err)})`, {
361
+ cause: err,
362
+ });
363
+ }
364
+ }
168
365
  // 异步派发任务消息,触发 agent 处理(不等待完成)
169
- const incoming = {
170
- content: task,
171
- sessionId: child.id,
172
- platform: callCtx.platform || 'internal',
173
- userId: `parent:${parentId}`,
174
- nickname: undefined,
175
- };
176
- // 授权身份透传(schema-message 的 actor 契约):userId 是归档用的物理来源标记
177
- //(authority 查不到 `parent:*`,等价匿名),授权身份走 actor——子任务工具以创建者的
178
- // 权限等级执行。优先 callCtx.actor(链式),否则物理身份;匿名则子任务同样匿名。
179
- const createActor = callCtx.actor ??
180
- (callCtx.platform && callCtx.userId ? { platform: callCtx.platform, userId: callCtx.userId } : undefined);
181
- if (createActor)
182
- incoming.actor = createActor;
183
- events.emit('inbound:message', incoming).catch(err => {
184
- logger.warn(`子任务消息派发失败 (${child.id}):`, err);
366
+ events.use.emit('inbound:message', taskMessage(callCtx, child.id, task)).catch(err => {
367
+ logger.use.warn(`子任务消息派发失败 (${child.id}):`, err);
185
368
  });
369
+ const name = nameOf(child);
186
370
  return JSON.stringify({
187
371
  subtaskId: child.id,
188
- name: taskName,
372
+ name,
189
373
  status: 'active',
190
- message: `子任务 "${taskName}" 已创建并开始执行`,
374
+ message: `子任务 "${name}" 已创建并开始执行`,
191
375
  });
192
376
  }
193
377
  catch (err) {
@@ -196,16 +380,19 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
196
380
  },
197
381
  });
198
382
  // ---- check_subtask ----
199
- tools.register({
383
+ tools.use.register({
200
384
  groups: ['subtask'],
385
+ // 只看不改:2 级、不确认,低于 2 级的看不到别人派出的子任务的任务与结果
386
+ visibility: 'restricted',
201
387
  definition: {
202
388
  type: 'function',
203
389
  function: {
204
390
  name: 'check_subtask',
205
391
  description: [
206
392
  '查询一个或多个子任务的当前状态和结果。',
207
- '返回每个子任务的状态(active=进行中, completed=已完成, error=出错)、结果摘要等。',
208
- '如果有子任务状态为 error,可使用 send_to_subtask 重新发送指令让其重试。',
393
+ '返回每个子任务的状态(active=进行中, completed=已完成, error=出错, stopped=已停止)、结果摘要等;出错的带 reason(出错原因)。',
394
+ '如果有子任务状态为 error,可在它销毁之前用 send_to_subtask 重新发送指令让其重试,已完成或出错的会重新进入进行中。',
395
+ '子任务交回结果后闲置一段时间没人追问就会销毁:销毁之后这里回它最后的状态与结果一次(destroyed 为 true),再查就是不存在,要接着做得重新创建。',
209
396
  ].join('\n'),
210
397
  parameters: {
211
398
  type: 'object',
@@ -221,50 +408,50 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
221
408
  },
222
409
  },
223
410
  },
224
- handler: async (args) => {
225
- const sm = sessionManager.current;
226
- if (!sm)
411
+ handler: async (args, callCtx) => {
412
+ if (!sessionManager.present)
227
413
  return JSON.stringify({ error: 'session-manager 服务不可用' });
414
+ const sm = sessionManager.use;
228
415
  const ids = args.subtask_ids || [];
229
416
  if (ids.length === 0) {
230
417
  return JSON.stringify({
231
418
  error: '缺少 subtask_ids 参数。正确用法: { "subtask_ids": ["<子任务ID1>", "<子任务ID2>"] }',
232
419
  });
233
420
  }
234
- const results = ids.map(id => {
235
- const session = sm.getSession(id);
236
- if (!session)
237
- return { id, error: '会话不存在' };
238
- const statusLabel = session.status === 'active'
239
- ? '进行中'
240
- : session.status === 'completed'
241
- ? '已完成'
242
- : session.status === 'error'
243
- ? '出错'
244
- : session.status;
245
- return {
421
+ const results = [];
422
+ for (const id of ids) {
423
+ const found = await lookup(sm, id, callCtx.sessionId);
424
+ if (!found) {
425
+ results.push({ id, error: NOT_FOUND });
426
+ continue;
427
+ }
428
+ const { report, destroyed } = found;
429
+ results.push({
246
430
  id,
247
- name: session.name,
248
- status: session.status,
249
- statusLabel,
250
- result: session.result || null,
251
- inputContext: session.inputContext || null,
252
- };
253
- });
431
+ name: report.name,
432
+ status: report.status,
433
+ statusLabel: STATUS_LABELS[report.status] ?? report.status,
434
+ reason: report.reason,
435
+ result: report.result ?? null,
436
+ inputContext: report.inputContext ?? null,
437
+ ...(destroyed ? { destroyed: true } : {}),
438
+ });
439
+ }
254
440
  const summary = {
255
441
  total: results.length,
256
442
  active: results.filter(r => !('error' in r) && r.status === 'active').length,
257
443
  completed: results.filter(r => !('error' in r) && r.status === 'completed').length,
258
444
  errored: results.filter(r => !('error' in r) && r.status === 'error').length,
445
+ stopped: results.filter(r => !('error' in r) && r.status === 'stopped').length,
259
446
  };
260
447
  return JSON.stringify({ summary, subtasks: results });
261
448
  },
262
449
  });
263
450
  // ---- send_to_subtask ----
264
- tools.register({
451
+ tools.use.register({
265
452
  groups: ['subtask'],
266
- // 向子任务追加指令 = 驱动其再跑一轮 LLM,同 create_subtask 的开销放大面。
267
- risk: 'sensitive',
453
+ // 追问让子任务再跑一轮:与 create_subtask 同一规则
454
+ risk: 'dangerous',
268
455
  definition: {
269
456
  type: 'function',
270
457
  function: {
@@ -272,7 +459,7 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
272
459
  description: [
273
460
  '向子任务会话发送追加消息(追问、补充指令等)。',
274
461
  '消息将作为父会话用户身份注入子任务,触发子任务 agent 继续处理。',
275
- '子任务如已完成,将被重新激活为 active 状态。',
462
+ '子任务如已完成或出错,将被重新激活为 active 状态,闲置计时从这一轮结束重新算;已销毁的子任务要重新创建。',
276
463
  ].join('\n'),
277
464
  parameters: {
278
465
  type: 'object',
@@ -292,63 +479,99 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
292
479
  },
293
480
  },
294
481
  handler: async (args, callCtx) => {
295
- const sm = sessionManager.current;
296
- if (!sm)
482
+ if (!sessionManager.present)
297
483
  return JSON.stringify({ error: 'session-manager 服务不可用' });
484
+ const sm = sessionManager.use;
298
485
  const subtaskId = String(args.subtask_id || '');
299
486
  const message = String(args.message || '');
300
487
  if (!subtaskId || !message)
301
488
  return JSON.stringify({ error: '缺少子任务 ID 或消息内容' });
302
- const session = sm.getSession(subtaskId);
489
+ if (!gateway.present)
490
+ return JSON.stringify({ error: '消息网关不可用,追问发不出' });
491
+ // 只向直接子任务发:防越权向任意会话注入消息、重激活已完成的会话
492
+ const session = directChild(sm, subtaskId, callCtx.sessionId);
303
493
  if (!session)
304
- return JSON.stringify({ error: `子任务不存在: ${subtaskId}` });
305
- // 归属校验:只能向本会话的直接子任务发消息,防越权向任意会话注入消息 / 重激活已完成会话
306
- //(与 delete_subtask 一致)。
307
- if (session.parentId !== callCtx.sessionId) {
308
- return JSON.stringify({
309
- error: `无权操作该子任务:${subtaskId} 不是当前会话 (${callCtx.sessionId}) 的子任务 (parentId=${session.parentId})。`,
310
- });
311
- }
312
- // 如果子任务已完成,重新激活
313
- if (session.status === 'completed') {
494
+ return JSON.stringify({ error: `${NOT_FOUND}: ${subtaskId}` });
495
+ // 已完成或出错的重新激活;归档的不翻(与回合开始时同一口径)
496
+ if (session.status === 'completed' || session.status === 'error') {
314
497
  await sm.updateSession(subtaskId, { status: 'active' });
315
498
  }
316
- // 派发消息
317
- const followUp = {
318
- content: message,
319
- sessionId: subtaskId,
320
- platform: callCtx.platform || 'internal',
321
- userId: `parent:${callCtx.sessionId}`,
322
- nickname: undefined,
323
- };
324
- // 授权身份透传:与 create_subtask 同源。缺了这一处会造成同一子任务会话的权限
325
- // 在轮次间跳变——创建轮有创建者等级、追问轮掉回匿名(2026-08-24 审计)。
326
- const followUpActor = callCtx.actor ??
327
- (callCtx.platform && callCtx.userId ? { platform: callCtx.platform, userId: callCtx.userId } : undefined);
328
- if (followUpActor)
329
- followUp.actor = followUpActor;
330
- events.emit('inbound:message', followUp).catch(err => {
331
- logger.warn(`向子任务发送消息失败 (${subtaskId}):`, err);
499
+ // 经网关送达、不等:发出到落定之间不判到期、这期间结束的一轮不交回,追问排在子任务在跑的那一轮之后另开一轮
500
+ followUps.set(subtaskId, (followUps.get(subtaskId) ?? 0) + 1);
501
+ const token = {};
502
+ live.add(token);
503
+ followUpTurn
504
+ .run(token, () => gateway.use.ingressMessage(taskMessage(callCtx, subtaskId, message)))
505
+ .catch(err => logger.use.warn(`向子任务发送消息失败 (${subtaskId}):`, err))
506
+ .finally(() => {
507
+ live.delete(token);
508
+ followUpSettled(subtaskId);
332
509
  });
333
510
  return JSON.stringify({
334
511
  subtaskId,
335
512
  sent: true,
336
- message: `消息已发送到子任务 "${session.name}"`,
513
+ message: `消息已发送到子任务 "${nameOf(session)}"`,
337
514
  });
338
515
  },
339
516
  });
517
+ // ---- set_subtask_setting ----
518
+ tools.use.register({
519
+ groups: ['subtask'],
520
+ // 模型给自己派的子会话放权等于自己给自己授权:与 create_subtask 同一规则,要 owner 确认(免确认只对以 owner 身份做的事
521
+ // 生效),项的门槛另按这一轮发起的人逐项判
522
+ risk: 'dangerous',
523
+ definition: {
524
+ type: 'function',
525
+ function: {
526
+ name: 'set_subtask_setting',
527
+ description: [
528
+ '改一个直接子任务(本会话派出的子会话,不含它再派出的)这个会话自己那一层的一项设置,下一轮起生效。',
529
+ '写法同 /session.set:key 是项名,几个插件同名时写「<实例 id>/<键>」;value 是取值的文字,模型写「<提供者实例 id>/<模型 id>」,列表用逗号分隔;列表与映射写 []、文字传空串即设成空。',
530
+ '只写值,不能恢复继承。项写错、不适用或重名时,回执列出能改的项。每次调用都要 owner 确认。',
531
+ ].join('\n'),
532
+ parameters: {
533
+ type: 'object',
534
+ properties: {
535
+ subtask_id: { type: 'string', description: '子任务 ID' },
536
+ key: { type: 'string', description: '项名,如 persona;同名时写「<实例 id>/<键>」' },
537
+ value: { type: 'string', description: '取值的文字;空列表写 [],空文字传空串' },
538
+ },
539
+ required: ['subtask_id', 'key', 'value'],
540
+ additionalProperties: false,
541
+ },
542
+ },
543
+ },
544
+ handler: async (args, callCtx) => {
545
+ if (!sessionManager.present)
546
+ return JSON.stringify({ error: 'session-manager 服务不可用' });
547
+ if (!sessionSettings.present)
548
+ return JSON.stringify({ error: '会话设置不在,改不了子任务的设置' });
549
+ const sm = sessionManager.use;
550
+ const subtaskId = String(args.subtask_id || '').trim();
551
+ const key = String(args.key || '').trim();
552
+ if (!subtaskId || !key)
553
+ return JSON.stringify({ error: '缺少子任务 ID 或项名' });
554
+ const session = directChild(sm, subtaskId, callCtx.sessionId);
555
+ if (!session)
556
+ return JSON.stringify({ error: `${NOT_FOUND}: ${subtaskId}` });
557
+ // 门槛按这次调用的授权身份:几个人的一轮是无主体,这一项声明了确认,多人的一轮里经 owner 批过,参与者已换成她
558
+ const receipt = await sessionSettings.use.setAs(actingIdentity(callCtx), subtaskId, key, String(args.value ?? ''));
559
+ // 给模型的一律原文
560
+ return toSource(receipt.text);
561
+ },
562
+ });
340
563
  // ---- delete_subtask ----
341
- tools.register({
564
+ tools.use.register({
342
565
  groups: ['subtask'],
343
- // 销毁子任务会话(含其未完成工作)——破坏性且影响他人发起的任务。
344
- risk: 'sensitive',
566
+ // 销毁子任务会话连同它没做完的事:与 create_subtask 同一规则
567
+ risk: 'dangerous',
345
568
  definition: {
346
569
  type: 'function',
347
570
  function: {
348
571
  name: 'delete_subtask',
349
572
  description: [
350
573
  '删除指定的子任务会话及其所有子会话(递归删除)。',
351
- '只能删除当前会话的直接或间接子任务,不能删除根会话或其他会话的子任务。',
574
+ '只能删除当前会话的直接子任务,不能删除根会话或其他会话的子任务。',
352
575
  '删除后会话的消息历史也会被一并清除。',
353
576
  ].join('\n'),
354
577
  parameters: {
@@ -365,24 +588,17 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
365
588
  },
366
589
  },
367
590
  handler: async (args, callCtx) => {
368
- const sm = sessionManager.current;
369
- if (!sm)
591
+ if (!sessionManager.present)
370
592
  return JSON.stringify({ error: 'session-manager 服务不可用' });
593
+ const sm = sessionManager.use;
371
594
  const subtaskId = String(args.subtask_id || '').trim();
372
595
  if (!subtaskId)
373
596
  return JSON.stringify({ error: '缺少子任务 ID' });
374
- const session = sm.getSession(subtaskId);
597
+ const session = directChild(sm, subtaskId, callCtx.sessionId);
375
598
  if (!session)
376
- return JSON.stringify({ error: `会话不存在: ${subtaskId}`, notFound: true });
377
- // 安全检查:只能删除当前会话的子任务
378
- if (session.parentId !== callCtx.sessionId) {
379
- return JSON.stringify({
380
- error: `无权删除该会话。当前会话 (${callCtx.sessionId}) 不是该会话的父会话 (parentId=${session.parentId})。`,
381
- denied: true,
382
- });
383
- }
599
+ return JSON.stringify({ error: `${NOT_FOUND}: ${subtaskId}` });
384
600
  try {
385
- const name = session.name;
601
+ const name = nameOf(session);
386
602
  await sm.deleteSession(subtaskId);
387
603
  return JSON.stringify({
388
604
  subtaskId,
@@ -397,20 +613,23 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
397
613
  },
398
614
  });
399
615
  // ---- wait_subtasks ----
400
- tools.register({
616
+ tools.use.register({
401
617
  groups: ['subtask'],
618
+ // 只看不改:同 check_subtask
619
+ visibility: 'restricted',
402
620
  definition: {
403
621
  type: 'function',
404
622
  function: {
405
623
  name: 'wait_subtasks',
406
624
  description: [
407
625
  '等待指定的子任务全部完成。阻塞直到所有子任务完成或超时。',
408
- '返回每个子任务的状态和结果摘要。超时后仍返回当前状态(部分可能未完成)。',
626
+ '返回每个子任务的状态和结果摘要,出错的带 reason(出错原因)。超时后仍返回当前状态(部分可能未完成)。被停止的子任务状态为 stopped(已停止),不算完成。',
409
627
  '',
410
628
  '【必须调用】创建子任务后,必须调用此工具等待所有子任务完成。',
411
629
  '不等待就继续主任务会导致:子任务结果丢失、文档内容不完整、最终输出质量差。',
412
630
  '调用后请检查返回的 allCompleted 字段和每个子任务的 result,确认所有子任务均成功完成。',
413
- '如有子任务失败或超时,可使用 send_to_subtask 补充指令后再次等待。',
631
+ '如有子任务失败或超时,可在它销毁之前使用 send_to_subtask 补充指令后再次等待,已完成或出错的会重新进入进行中。',
632
+ '交回结果后闲置到期而销毁的子任务,这里回它最后的状态与结果一次(destroyed 为 true),之后就是不存在,要重新创建。',
414
633
  ].join('\n'),
415
634
  parameters: {
416
635
  type: 'object',
@@ -430,10 +649,10 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
430
649
  },
431
650
  },
432
651
  },
433
- handler: async (args, _callCtx) => {
434
- const sm = sessionManager.current;
435
- if (!sm)
652
+ handler: async (args, callCtx) => {
653
+ if (!sessionManager.present)
436
654
  return JSON.stringify({ error: 'session-manager 服务不可用' });
655
+ const sm = sessionManager.use;
437
656
  const ids = args.subtask_ids || [];
438
657
  if (ids.length === 0) {
439
658
  return JSON.stringify({
@@ -442,61 +661,65 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
442
661
  }
443
662
  const timeoutSec = Number(args.timeout_seconds) || cfg.maxWaitMs / 1000;
444
663
  const timeoutMs = Math.min(timeoutSec * 1000, cfg.maxWaitMs);
445
- // 先检查是否已经全部完成(避免不必要的等待)
664
+ // 先检查是否已经全部结束(避免不必要的等待);不是本会话直接子任务的不等
446
665
  const isTerminal = (id) => {
447
- const s = sm.getSession(id);
448
- return !s || s.status === 'completed' || s.status === 'error';
666
+ const s = directChild(sm, id, callCtx.sessionId);
667
+ const status = s && statusOf(s);
668
+ return !status || status === 'completed' || status === 'error' || status === 'stopped';
449
669
  };
450
- if (!ids.every(isTerminal)) {
451
- // 使用事件驱动 + 超时,替代轮询
670
+ const { signal } = callCtx;
671
+ if (!ids.every(isTerminal) && !signal?.aborted) {
672
+ // 事件驱动 + 超时,替代轮询:会话完成、状态变了、被删(结果里按不存在回)或追问落定时重判;调用被中止(这一轮被停止键、
673
+ // 禁言、/clear 等停下)时不再等,这一轮才落定得了
452
674
  await new Promise(resolve => {
453
675
  const pending = new Set(ids.filter(id => !isTerminal(id)));
454
- const cleanup = () => {
676
+ const disposers = [];
677
+ const finish = () => {
455
678
  clearTimeout(timer);
456
- disposeCompleted();
457
- disposeUpdated();
458
- };
459
- // 监听 session:completed 事件
460
- const disposeCompleted = events.on('session:completed', (session) => {
461
- pending.delete(session.id);
462
- if (pending.size === 0) {
463
- cleanup();
464
- resolve();
465
- }
466
- });
467
- // 兜底:也监听 session:updated 以防状态通过 updateSession 变更
468
- const disposeUpdated = events.on('session:updated', (session) => {
469
- if ((session.status === 'completed' || session.status === 'error') && pending.has(session.id)) {
470
- pending.delete(session.id);
471
- if (pending.size === 0) {
472
- cleanup();
473
- resolve();
474
- }
475
- }
476
- });
477
- // 超时保护
478
- const timer = setTimeout(() => {
479
- cleanup();
679
+ for (const dispose of disposers)
680
+ dispose();
681
+ onFollowUpSettled.delete(recheck);
682
+ signal?.removeEventListener('abort', finish);
480
683
  resolve();
481
- }, timeoutMs);
684
+ };
685
+ const recheck = () => {
686
+ for (const id of pending)
687
+ if (isTerminal(id))
688
+ pending.delete(id);
689
+ if (pending.size === 0)
690
+ finish();
691
+ };
692
+ const timer = setTimeout(finish, timeoutMs);
693
+ disposers.push(events.use.on('session:completed', recheck), events.use.on('session:updated', recheck), events.use.on('session:deleted', recheck));
694
+ onFollowUpSettled.add(recheck);
695
+ signal?.addEventListener('abort', finish, { once: true });
482
696
  });
483
697
  }
484
- // 收集结果
485
- const results = ids.map(id => {
486
- const session = sm.getSession(id);
487
- if (!session)
488
- return { id, error: '会话不存在' };
489
- return {
698
+ if (signal?.aborted)
699
+ return JSON.stringify({ error: '回合已中止,不再等' });
700
+ // 收集结果:等着时销毁了的取回报
701
+ const results = [];
702
+ for (const id of ids) {
703
+ const found = await lookup(sm, id, callCtx.sessionId);
704
+ if (!found) {
705
+ results.push({ id, error: NOT_FOUND });
706
+ continue;
707
+ }
708
+ const { report, destroyed } = found;
709
+ results.push({
490
710
  id,
491
- name: session.name,
492
- status: session.status,
493
- inputContext: session.inputContext || null,
494
- result: session.result || null,
495
- completed: session.status === 'completed',
496
- };
497
- });
711
+ name: report.name,
712
+ status: report.status,
713
+ reason: report.reason,
714
+ inputContext: report.inputContext ?? null,
715
+ result: report.result ?? null,
716
+ completed: report.status === 'completed',
717
+ ...(destroyed ? { destroyed: true } : {}),
718
+ });
719
+ }
498
720
  const allCompleted = results.every(r => 'completed' in r && r.completed);
499
- const timedOut = !allCompleted;
721
+ // 出错、被停止的已经结束,不算超时:只有等满时还没结束的才算
722
+ const timedOut = !ids.every(isTerminal);
500
723
  return JSON.stringify({
501
724
  allCompleted,
502
725
  timedOut,
@@ -510,12 +733,12 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
510
733
  const PARENT_CONTEXT_MARKER = '--- 活跃子任务提醒 ---';
511
734
  /** 提醒消息的 injector 标识:每轮重注时按它摘旧、token 统计按它归桶 */
512
735
  const PARENT_STATUS_INJECTOR = 'subtask/parent-status';
513
- hooks.middleware('agent:llm:before', async (data, next) => {
514
- const sm = sessionManager.current;
515
- if (!sm) {
736
+ hooks.use.middleware('agent:llm:before', async (data, next) => {
737
+ if (!sessionManager.present) {
516
738
  await next();
517
739
  return;
518
740
  }
741
+ const sm = sessionManager.use;
519
742
  const sessionId = data.sessionId;
520
743
  if (!sessionId) {
521
744
  await next();
@@ -540,7 +763,7 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
540
763
  const subtaskContext = [
541
764
  `\n\n--- 子任务上下文 ---`,
542
765
  `你正在一个子任务会话中执行独立任务。`,
543
- `子任务名称: ${session.name}`,
766
+ `子任务名称: ${nameOf(session)}`,
544
767
  session.inputContext ? `任务指令: ${session.inputContext}` : '',
545
768
  '',
546
769
  '回答规则:',
@@ -548,11 +771,9 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
548
771
  '- 系统会自动将你的最终回复作为任务结果报告给父会话',
549
772
  '- 请给出完整、有价值的回答,因为你的回复内容将直接成为子任务结果',
550
773
  '',
551
- '共享资源规则:',
552
- '- 如果任务指令中包含 docId(如 doc-xxxxxxxx),直接使用该 ID 操作文档,不要创建新文档',
553
- '- 严格只操作你负责的范围(如指定的幻灯片编号、工作表等),不要越界修改其他部分',
554
- '- 完成后请在回复中汇报你做了什么(如添加了哪些幻灯片、内容摘要等),方便父会话整合',
555
- '- 不要调用 save 操作——由父会话统一保存',
774
+ '协作规则:',
775
+ '- 严格只操作你负责的范围(如指定的部分),不要越界修改其他部分',
776
+ '- 完成后请在回复中汇报你做了什么(如做了哪些改动、内容摘要等),方便父会话整合',
556
777
  '--- 子任务上下文结束 ---',
557
778
  ]
558
779
  .filter(Boolean)
@@ -574,26 +795,35 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
574
795
  // 工具循环中 wait_subtasks 返回后,下一轮 LLM 看到的是刷新过的状态而非首轮快照
575
796
  // (这也是它不走 agent:prompt 贡献点的原因:贡献按全局键幂等,物化一次后同回合
576
797
  // 不再刷新)。
798
+ // 子会话销毁之后,它的回报在父会话记录上:也要列出,否则最后一个子会话销毁后回报就没人列
577
799
  if (!session?.parentId && session) {
578
800
  const children = session.children;
579
- if (children && children.length > 0) {
801
+ // 子会话还在的回报不列:写回报与撤回之间(这一拍里有追问)它还活着,不能说成已销毁
802
+ const destroyed = reportsOf(session).filter(report => !sm.getSession(report.id));
803
+ if (children.length > 0 || destroyed.length > 0) {
580
804
  const activeChildren = [];
581
805
  const completedChildren = [];
582
806
  const errorChildren = [];
807
+ const stoppedChildren = [];
583
808
  for (const childId of children) {
584
809
  const child = sm.getSession(childId);
585
810
  if (!child)
586
811
  continue;
587
- if (child.status === 'active') {
588
- activeChildren.push(` - [进行中] ${child.name} (ID: ${childId})`);
812
+ const status = statusOf(child);
813
+ if (status === 'active') {
814
+ activeChildren.push(` - [进行中] ${nameOf(child)} (ID: ${childId})`);
589
815
  }
590
- else if (child.status === 'completed') {
816
+ else if (status === 'completed') {
591
817
  const resultPreview = child.result ?? '(无结果)';
592
- completedChildren.push(` - [已完成] ${child.name} (ID: ${childId}): ${resultPreview}`);
818
+ completedChildren.push(` - [已完成] ${nameOf(child)} (ID: ${childId}): ${resultPreview}`);
819
+ }
820
+ else if (status === 'error') {
821
+ // 不取 result:出错时它若有值,是此前某一轮回复收口时留下的结果,不是这次出错的详情
822
+ const reason = toSource(child.lastTurn?.error) ?? '(无错误详情)';
823
+ errorChildren.push(` - [出错] ${nameOf(child)} (ID: ${childId}): ${reason}`);
593
824
  }
594
- else if (child.status === 'error') {
595
- const resultPreview = child.result ?? '(无错误详情)';
596
- errorChildren.push(` - [出错] ${child.name} (ID: ${childId}): ${resultPreview}`);
825
+ else if (status === 'stopped') {
826
+ stoppedChildren.push(` - [已停止] ${nameOf(child)} (ID: ${childId})`);
597
827
  }
598
828
  }
599
829
  // 每轮重注:先摘上一轮的提醒消息(按 injector 识别,替代旧的字符串手术)
@@ -601,7 +831,11 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
601
831
  if (messages[i].metadata?.injector === PARENT_STATUS_INJECTOR)
602
832
  messages.splice(i, 1);
603
833
  }
604
- if (activeChildren.length > 0 || completedChildren.length > 0 || errorChildren.length > 0) {
834
+ if (activeChildren.length > 0 ||
835
+ completedChildren.length > 0 ||
836
+ errorChildren.length > 0 ||
837
+ stoppedChildren.length > 0 ||
838
+ destroyed.length > 0) {
605
839
  const lines = [PARENT_CONTEXT_MARKER];
606
840
  if (activeChildren.length > 0) {
607
841
  lines.push(`⚠️ 你有 ${activeChildren.length} 个子任务正在执行:`);
@@ -613,14 +847,33 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
613
847
  lines.push(`❌ 以下子任务执行出错:`);
614
848
  lines.push(...errorChildren);
615
849
  lines.push('');
616
- lines.push('可使用 send_to_subtask 向出错的子任务发送修正指令让其重试,或使用 check_subtask 查看详细状态。');
850
+ lines.push('可在销毁之前使用 send_to_subtask 向出错的子任务发送修正指令让其重试(已完成或出错的会重新进入进行中),或使用 check_subtask 查看详细状态;销毁了的要重新创建。');
851
+ }
852
+ if (stoppedChildren.length > 0) {
853
+ lines.push(`以下子任务已被停止:`);
854
+ lines.push(...stoppedChildren);
617
855
  }
618
856
  if (completedChildren.length > 0) {
619
857
  lines.push(`以下子任务已完成,请检查结果:`);
620
858
  lines.push(...completedChildren);
621
859
  }
860
+ if (destroyed.length > 0) {
861
+ lines.push('以下子任务交回结果后闲置到期,已销毁;这是它们最后的状态与结果,只列这一轮,要接着做得重新创建:');
862
+ for (const report of destroyed) {
863
+ const detail = report.status === 'error' ? (report.reason ?? '(无错误详情)') : report.result;
864
+ const label = STATUS_LABELS[report.status] ?? report.status;
865
+ lines.push(` - [${label}] ${report.name} (ID: ${report.id})${detail ? `: ${detail}` : ''}`);
866
+ }
867
+ // 干跑(用量快照)不算列出:那不是这一轮给模型看的
868
+ if (!data.dryRun) {
869
+ const ids = listed.get(sessionId) ?? new Set();
870
+ for (const report of destroyed)
871
+ ids.add(report.id);
872
+ listed.set(sessionId, ids);
873
+ }
874
+ }
622
875
  lines.push('--- 活跃子任务提醒结束 ---');
623
- // 首轮(最后一条是当前 user 消息)插它前面;工具轮/委派轮(尾部是
876
+ // 首轮(最后一条是当前 user 消息)插它前面;工具轮/proactive 轮(尾部是
624
877
  // tool/assistant/system)追加到末尾 = 决策点前,状态最新鲜的位置
625
878
  const insertAt = messages[messages.length - 1]?.role === 'user' ? messages.length - 1 : messages.length;
626
879
  messages.splice(insertAt, 0, {
@@ -636,27 +889,33 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
636
889
  // ---- 子任务自动完成 ----
637
890
  // 当子任务 agent 正常回复后,自动将回复内容作为结果完成会话
638
891
  // 同时在子会话历史中合成一条 tool call 记录,展示"报告给父会话"的流程
639
- hooks.middleware('agent:turn:after', async (data, next) => {
892
+ hooks.use.middleware('agent:turn:after', async (data, next) => {
640
893
  await next();
641
894
  // 仅在本轮真正产生回复时回报父会话;silent / aborted 不应触发自动完成
642
895
  if (data.outcome !== 'replied')
643
896
  return;
644
- const sm = sessionManager.current;
645
- if (!sm)
897
+ if (!sessionManager.present)
646
898
  return;
899
+ const sm = sessionManager.use;
647
900
  const session = sm.getSession(data.sessionId);
648
901
  if (!session?.parentId)
649
902
  return; // 非子任务
650
903
  if (session.status === 'completed' || session.status === 'error')
651
904
  return; // 已完成
905
+ // 还有不在这一轮里的追问没落定(追问开的一轮只有它自己那一条):这一轮的回复不交回、不完成,等追问那一轮
906
+ const own = followUpTurn.getStore();
907
+ if ((followUps.get(data.sessionId) ?? 0) > (own && live.has(own) ? 1 : 0))
908
+ return;
652
909
  const result = data.reply;
653
910
  if (!result || result.trim().length === 0)
654
911
  return;
655
912
  try {
656
913
  // 在子会话历史中合成 tool call 记录,展示报告流程
657
- const archive = messageArchive.current;
914
+ const archive = messageArchive.present ? messageArchive.use : undefined;
658
915
  if (archive) {
659
916
  const syntheticToolCallId = `report-${Date.now()}`;
917
+ // 两条都带这一轮的回合 id:连对话回滚这一轮时随它删
918
+ const metadata = { [WellKnownMetadataKeys.TurnId]: data.turnId };
660
919
  // 合成 assistant 消息(含 tool_calls)
661
920
  await archive.saveMessage(data.sessionId, {
662
921
  role: 'assistant',
@@ -672,6 +931,7 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
672
931
  },
673
932
  ],
674
933
  timestamp: Date.now(),
934
+ metadata,
675
935
  });
676
936
  // 合成 tool 结果消息
677
937
  await archive.saveMessage(data.sessionId, {
@@ -679,15 +939,179 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
679
939
  content: JSON.stringify({ success: true, message: '结果已报告给父会话' }),
680
940
  toolCallId: syntheticToolCallId,
681
941
  timestamp: Date.now(),
942
+ metadata,
682
943
  });
683
944
  }
684
945
  await sm.completeSession(data.sessionId, result.trim());
685
- logger.info(`子任务自动完成: ${session.name} (${data.sessionId})`);
946
+ logger.use.info(`子任务自动完成: ${nameOf(session)} (${data.sessionId})`);
947
+ }
948
+ catch (err) {
949
+ logger.use.warn(`子任务自动完成失败 (${data.sessionId}):`, err);
950
+ }
951
+ });
952
+ // ---- 到期销毁 ----
953
+ // 子会话的一轮结束(不论结局)满 idleDeleteHours 就删掉,追问让它在下一轮结束时重新计时,从没跑过一轮的不计时。在飞的一轮与
954
+ // 发出还没落定的追问由本插件自己记,不看会话状态(归档的子会话跑一轮时状态不翻;追问之前那一轮先把状态收口了)。有在跑的
955
+ // 后台任务的同样不删:正常结束的由结束通知开的那一轮收尾时重排,被停止、中断的不发结束通知,结束时重排。
956
+ // 删除只在定时器里做:删除要等这个会话的回合收尾,在回合里删就互相等。销毁之前把回报写进父会话记录
957
+ /** 这个子会话有在跑的后台任务:正常结束时结束通知开一轮,那一轮收尾时重新计时 */
958
+ const hasRunningTask = (sessionId) => backgroundTasks.present && backgroundTasks.use.list({ sessionId }).some(task => task.status === 'running');
959
+ /** 起点:一轮结束的时刻;从没跑过一轮的没有 */
960
+ const startOf = (child) => endedAt.get(child.id) ?? child.lastTurn?.endedAt;
961
+ /** 排这个子会话的到期定时器(离到期还差多久与一天取较短的,到点重判);不是子会话或不计时的不排 */
962
+ function schedule(childId) {
963
+ clearTimeout(timers.get(childId));
964
+ timers.delete(childId);
965
+ const child = tracked?.getSession(childId);
966
+ const start = child?.parentId === undefined ? undefined : startOf(child);
967
+ if (start === undefined)
968
+ return;
969
+ const left = start + cfg.idleDeleteHours * HOUR_MS - Date.now();
970
+ timers.set(childId, setTimeout(() => void expire(childId), Math.min(Math.max(left, 0), MAX_TIMER_MS)));
971
+ }
972
+ /**
973
+ * 到点重判:一轮在飞、追问还没落定或有在跑的后台任务的等那一轮收尾、追问落定、任务结束时再排;没到期的再排;到期的写回报、删掉(父会话在它还在时已经取走
974
+ * 过同一份结果的不写回报)。写回报要等一拍:这一拍里发出的追问让它忙起来(或会话管理换了)时撤回这条回报、不删,由那一轮收尾
975
+ * 或追问落定时重新排
976
+ */
977
+ async function expire(childId) {
978
+ timers.delete(childId);
979
+ const sm = tracked;
980
+ const child = sm?.getSession(childId);
981
+ if (!sm || child?.parentId === undefined)
982
+ return;
983
+ const busy = () => inflight.has(childId) || followUps.has(childId) || hasRunningTask(childId);
984
+ if (busy())
985
+ return;
986
+ const start = startOf(child);
987
+ if (start === undefined)
988
+ return;
989
+ if (Date.now() < start + cfg.idleDeleteHours * HOUR_MS) {
990
+ schedule(childId);
991
+ return;
992
+ }
993
+ const parentId = child.parentId;
994
+ /** 父会话记录上别的子任务的回报 */
995
+ const others = () => reportsOf(sm.getSession(parentId)).filter(r => r.id !== childId);
996
+ const report = reportOf(child, statusOf(child));
997
+ // 父会话在它还在时已经取走过这一份结果:不再写回报
998
+ const fresh = taken.get(childId) !== JSON.stringify(report);
999
+ try {
1000
+ if (fresh && sm.getSession(parentId))
1001
+ await writeReports(sm, parentId, [...others(), report]);
1002
+ if (busy() || tracked !== sm) {
1003
+ if (sm.getSession(parentId))
1004
+ await writeReports(sm, parentId, others());
1005
+ return;
1006
+ }
1007
+ await sm.deleteSession(childId);
1008
+ logger.use.info(`子任务交回结果后闲置到期,已销毁: ${nameOf(child)} (${childId})`);
1009
+ }
1010
+ catch (err) {
1011
+ logger.use.warn(`子任务到期销毁失败 (${childId}):`, err);
1012
+ }
1013
+ }
1014
+ /** 这个会话这一轮的提醒里列出过的回报:这一轮回复或静默收尾时删 */
1015
+ async function dropListed(sessionId) {
1016
+ const ids = listed.get(sessionId);
1017
+ const sm = tracked;
1018
+ if (!ids || !sm)
1019
+ return;
1020
+ listed.delete(sessionId);
1021
+ const reports = reportsOf(sm.getSession(sessionId));
1022
+ const rest = reports.filter(r => !ids.has(r.id));
1023
+ if (rest.length === reports.length)
1024
+ return;
1025
+ try {
1026
+ await writeReports(sm, sessionId, rest);
686
1027
  }
687
1028
  catch (err) {
688
- logger.warn(`子任务自动完成失败 (${data.sessionId}):`, err);
1029
+ logger.use.warn(`删除已列出的子任务回报失败 (${sessionId}):`, err);
689
1030
  }
1031
+ }
1032
+ hooks.use.middleware('agent:turn:after', async (data, next) => {
1033
+ outcomes.set(data.turnId, data.outcome);
1034
+ await next();
1035
+ });
1036
+ // 一轮从开始(agent:turn:before)到它的 next 返回为止:子会话记在飞,结束时记下时刻、排定时器;父会话这一轮回复或静默收尾时
1037
+ // 删掉这一轮列出过的回报,出错、被停止(或没走到 agent:turn:after)的模型没交出什么,回报留着、下一轮照常列出
1038
+ hooks.use.middleware('agent:turn:before', async (data, next) => {
1039
+ const { sessionId, turnId } = data;
1040
+ const { source } = data.inputs[0].message;
1041
+ const sm = sessionManager.present ? sessionManager.use : undefined;
1042
+ const session = sm?.getSession(sessionId);
1043
+ // 已删的子会话:发出、还没开轮的派发与追问不跑(这一轮不归档)
1044
+ if (sm && source === SUBTASK_SOURCE && !session)
1045
+ return;
1046
+ const child = session?.parentId !== undefined;
1047
+ if (child)
1048
+ inflight.set(sessionId, (inflight.get(sessionId) ?? 0) + 1);
1049
+ try {
1050
+ await next();
1051
+ }
1052
+ finally {
1053
+ if (child) {
1054
+ const rest = (inflight.get(sessionId) ?? 1) - 1;
1055
+ if (rest > 0)
1056
+ inflight.set(sessionId, rest);
1057
+ else
1058
+ inflight.delete(sessionId);
1059
+ if (tracked?.getSession(sessionId)) {
1060
+ endedAt.set(sessionId, Date.now());
1061
+ schedule(sessionId);
1062
+ }
1063
+ }
1064
+ const outcome = outcomes.get(turnId);
1065
+ outcomes.delete(turnId);
1066
+ if (outcome === 'replied' || outcome === 'silent')
1067
+ await dropListed(sessionId);
1068
+ else
1069
+ listed.delete(sessionId);
1070
+ }
1071
+ });
1072
+ // 后台任务被停止或中断:不发结束通知、不开一轮,就在这时重排(正常结束的由结束通知那一轮收尾时重排);不是子会话的不排
1073
+ events.use.on('background:changed', change => {
1074
+ if (!('task' in change))
1075
+ return;
1076
+ const { sessionId, status } = change.task;
1077
+ if ((status === 'stopped' || status === 'aborted') && !inflight.has(sessionId))
1078
+ schedule(sessionId);
1079
+ });
1080
+ events.use.on('session:deleted', sessionId => {
1081
+ clearTimeout(timers.get(sessionId));
1082
+ timers.delete(sessionId);
1083
+ followUps.delete(sessionId);
1084
+ endedAt.delete(sessionId);
1085
+ listed.delete(sessionId);
1086
+ taken.delete(sessionId);
1087
+ });
1088
+ // 父会话的消息历史清成了:回报一并清掉(在 next 之后看结果,与会话管理连带删除子会话同一判法);会话不在表里时什么都不做
1089
+ hooks.use.middleware('memory:clear', async (data, next) => {
1090
+ await next();
1091
+ if (!historyCleared(data))
1092
+ return;
1093
+ const sm = sessionManager.present ? sessionManager.use : undefined;
1094
+ if (!sm)
1095
+ return;
1096
+ const targets = data.scope === 'all' ? sm.listSessions() : [sm.getSession(data.sessionId ?? '')];
1097
+ for (const session of targets) {
1098
+ if (session && reportsOf(session).length > 0)
1099
+ await writeReports(sm, session.id, []);
1100
+ }
1101
+ });
1102
+ // 会话管理上线(含本插件激活、会话管理晚到)时扫一遍子会话排定时器;下线与关闭时撤掉
1103
+ sessionManager.follow(sm => {
1104
+ tracked = sm;
1105
+ for (const session of sm.listSessions())
1106
+ if (session.parentId !== undefined)
1107
+ schedule(session.id);
1108
+ return () => {
1109
+ tracked = undefined;
1110
+ for (const timer of timers.values())
1111
+ clearTimeout(timer);
1112
+ timers.clear();
1113
+ };
690
1114
  });
691
- logger.info('子任务工具已注册');
1115
+ logger.use.info('子任务工具已注册');
692
1116
  }
693
1117
  //# sourceMappingURL=index.js.map