@aalis/plugin-subtask 0.14.0 → 0.15.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/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,44 +23,75 @@ 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
  // =====================================================================
23
31
  /**
24
32
  * 派发与追问消息的来源:它们是内部注入,不是真人当面发的话。指令的意图确认只认同一会话里不带 source 的下一条消息,
25
33
  * 不带 source 时父会话经 send_to_subtask 发来的 y 会被当成发出者本人的确认(userId 同为 `parent:<父会话>`),
26
- * 父会话里的模型就能替子任务里的指令确认;要求真人当面发起的工具(白纸交任务等)也会放行子任务回合。
27
- * 派发与追问同一个值,共用一条 lane,追问照旧打断子任务在跑的那一轮。
34
+ * 父会话里的模型就能替子任务里的指令确认;要求真人当面发起的工具也会放行子任务回合。
35
+ * 派发与追问同一个值,各自成一轮:追问排在子任务在跑的那一轮之后。
28
36
  */
29
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
+ }
30
71
  const configSchema = defineConfig({
31
- enabled: { type: 'boolean', label: '启用子任务工具', default: true, onInvalid: 'error' },
32
- maxWaitMs: { type: 'number', label: '单次等待最大时长 (ms)', default: 300000 },
33
- defaultProvider: {
34
- type: 'string',
35
- label: '子任务默认 LLM provider',
36
- default: '',
37
- onInvalid: 'error',
38
- description: '不填则继承父会话。建议填轻量本地模型(如 ollama)让子任务跑在更便宜的模型上,节省 token',
39
- },
40
- defaultModel: {
41
- type: 'string',
42
- label: '子任务默认模型名',
43
- default: '',
44
- onInvalid: 'error',
45
- 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 },
46
81
  },
82
+ idleDeleteHours: { type: 'number', label: t('idleLabel'), description: t('idleDescription'), default: 24, min: 0 },
47
83
  });
48
- function subtaskStatus(session) {
49
- return session.status === 'completed' && session.lastTurn?.outcome === 'aborted' ? 'stopped' : session.status;
50
- }
51
- /** 出错的子任务的出错原因(session-manager 记在 lastTurn),其余状态不带:为 undefined 的键序列化后不出现在工具结果里 */
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
+ */
52
93
  function errorReason(session, status) {
53
- return status === 'error' ? session.lastTurn?.error : undefined;
94
+ return status === 'error' ? toSource(session.lastTurn?.error) : undefined;
54
95
  }
55
96
  const STATUS_LABELS = {
56
97
  active: '进行中',
@@ -58,6 +99,47 @@ const STATUS_LABELS = {
58
99
  error: '出错',
59
100
  stopped: '已停止',
60
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
+ }
61
143
  // ===== 插件入口 =====
62
144
  const uses = {
63
145
  tools: optional(tools),
@@ -67,39 +149,119 @@ const uses = {
67
149
  config,
68
150
  sessionManager: optional(sessionManager),
69
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),
70
167
  };
71
168
  export default definePlugin({
72
169
  name: '@aalis/plugin-subtask',
73
- displayName: '子任务',
170
+ displayName: t('displayName'),
74
171
  subsystem: 'session',
75
172
  configSchema,
76
173
  uses,
77
174
  apply(caps) {
78
175
  const parsed = parseConfig(configSchema, caps.config.use, caps.logger.use);
176
+ if (!parsed.enabled)
177
+ return;
79
178
  const cfg = {
80
- ...parsed,
81
179
  maxWaitMs: parsed.maxWaitMs || configSchema.maxWaitMs.default,
82
- defaultProvider: parsed.defaultProvider.trim(),
83
- defaultModel: parsed.defaultModel.trim(),
180
+ idleDeleteHours: parsed.idleDeleteHours,
84
181
  };
85
- if (!cfg.enabled)
86
- return;
87
- registerSubtask(caps, cfg);
182
+ registerSubtask(caps, cfg, caps.sessionSettings.use.fields(configSchema, caps));
88
183
  },
89
184
  });
90
- 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;
91
252
  // 注册工具分组
92
253
  tools.use.registerGroup({
93
254
  name: 'subtask',
94
- label: '子任务管理',
255
+ label: t('groupLabel'),
256
+ summary: t('groupSummary'),
95
257
  description: '创建、管理和协调子任务会话,支持并行执行',
96
258
  });
97
259
  // ---- create_subtask ----
98
260
  tools.use.register({
99
261
  groups: ['subtask'],
100
- // 每个子任务是一条独立的 LLM 会话链——不受信任的调用方可连续创建以放大 API 开销。
101
- // sensitive(L1) 挡住 level-0,不加逐次 confirm(正常用法是 agent 连续创建多个并行子任务)。
102
- risk: 'sensitive',
262
+ // 子任务替发起的人在后台接着做(每个是一条独立的 LLM 会话链):建、追问、改设置与删都与编排、定时任务、停后台任务同一规则,
263
+ // 2 级加确认。别人单独发起的没人能批即拒;owner 一个人的回合在免确认白名单里不问,常连续派几个并行子任务的把它加进白名单
264
+ risk: 'dangerous',
103
265
  definition: {
104
266
  type: 'function',
105
267
  function: {
@@ -110,16 +272,16 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
110
272
  '你可以连续调用多次 create_subtask 来创建多个并行子任务。',
111
273
  '',
112
274
  '【重要协作规范】',
113
- '1. 创建子任务前,先用 manage_todo_list 列出计划,包含"创建子任务"和"等待子任务完成"两步',
114
- '2. 共享文档协作:如果多个子任务需要操作同一个文档(如 PPT、Word、Excel),先在主会话中创建文档拿到 docId,然后在 task 中明确告知子任务该 docId——子任务会直接操作同一文档,无需各自创建',
115
- '3. task 描述中应包含:(a) 具体任务内容 (b) 需要使用的共享资源 ID(如 docId)(c) 子任务负责的范围(如"负责第3-4张幻灯片")',
116
- '4. 创建完所有子任务后,必须立即调用 wait_subtasks 等待结果,不要在未等待的情况下继续主任务',
117
- '5. 子任务无法调用 create_subtask(不可嵌套);保持 save 操作在主会话中执行',
275
+ '1. task 描述中应写清:(a) 具体任务内容 (b) 要用到的共享资源 (c) 子任务负责的范围',
276
+ '2. 创建完所有子任务后,必须立即调用 wait_subtasks 等待结果,不要在未等待的情况下继续主任务',
277
+ '3. 子任务无法调用 create_subtask(不可嵌套)',
118
278
  '',
119
- '【模型选择】可选 provider+model 参数为子任务指定更便宜/轻量的模型:',
279
+ '【模型选择】可选 model 参数为子任务指定更便宜/轻量的模型,写条目 id「<提供者实例 id>/<模型 id>」,与 /model 列出的写法相同:',
120
280
  '- 适合轻量模型的任务:语法检查、格式转换、摄取信息、翻译、简单总结、文档快速审阅',
121
281
  '- 依然需要父同模型的任务:复杂推理、多步规划、代码生成、独立决策',
122
- '- 不填则使用插件默认子任务模型,都未配置则继承父会话',
282
+ '- 不填则用本会话的子任务模型,没设时与本会话的模型相同',
283
+ '',
284
+ '子任务交回结果(一轮结束,不论回复、出错还是被停止)后闲置一段时间没人追问就会销毁,销毁后要重新创建。',
123
285
  ].join('\n'),
124
286
  parameters: {
125
287
  type: 'object',
@@ -132,13 +294,9 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
132
294
  type: 'string',
133
295
  description: '子任务显示名称(可选,默认自动生成)',
134
296
  },
135
- provider: {
136
- type: 'string',
137
- description: '可选:为子任务指定 LLM provider(如 ollama 、 deepseek)。与 model 配套使用,二者同填才会生效。',
138
- },
139
297
  model: {
140
298
  type: 'string',
141
- description: '可选:为子任务指定 model 名称(如 qwen3:8b、deepseek-chat)。二者同填才会生效;未填则使用插件默认或父会话模型。',
299
+ description: '可选:为子任务指定模型,写条目 id「<提供者实例 id>/<模型 id>」(模型 id 里可以有斜杠);不填则用本会话的子任务模型,没设时与本会话的模型相同。',
142
300
  },
143
301
  },
144
302
  required: ['task'],
@@ -159,55 +317,61 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
159
317
  if (parentSession?.parentId) {
160
318
  return JSON.stringify({ error: '子任务中不能再创建子任务。请直接完成当前任务,由父会话负责任务拆分和协调。' });
161
319
  }
162
- const taskName = args.name
163
- ? String(args.name)
164
- : `子任务 ${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;
165
338
  try {
166
- // 子会话复制父会话的生效配置;登记为 liveOnly 的项(白纸与远端代理等)不随复制冻结,只经继承链实时解析
167
- const inheritedConfig = sm.childConfig(parentId, callCtx.platform);
168
- // 模型优先级:工具参数 > 插件默认 > 父会话继承
169
- // 仅当 provider 和 model 同时提供才覆盖,避免半填产生歧义
170
- const argProvider = String(args.provider ?? '').trim();
171
- const argModel = String(args.model ?? '').trim();
172
- let llmOverride;
173
- if (argProvider && argModel) {
174
- llmOverride = { provider: argProvider, model: argModel };
175
- }
176
- else if (cfg.defaultProvider && cfg.defaultModel) {
177
- llmOverride = { provider: cfg.defaultProvider, model: cfg.defaultModel };
178
- }
179
- const childConfig = llmOverride ? { ...inheritedConfig, llm: llmOverride } : { ...inheritedConfig };
180
- // 创建子会话(存储 platform 用于后续自动触发)
181
339
  const child = await sm.createChildSession(parentId, {
182
340
  name: taskName,
341
+ title: taskName,
183
342
  inputContext: task,
184
- config: childConfig,
185
- metadata: { platform: callCtx.platform || 'internal', title: taskName },
343
+ metadata: sessionSettings.use.childMetadata(parentId, { platform: callCtx.platform }),
186
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
+ }
187
365
  // 异步派发任务消息,触发 agent 处理(不等待完成)
188
- const incoming = {
189
- content: task,
190
- sessionId: child.id,
191
- platform: callCtx.platform || 'internal',
192
- userId: `parent:${parentId}`,
193
- nickname: undefined,
194
- source: SUBTASK_SOURCE,
195
- };
196
- // 授权身份透传(schema-message 的 actor 契约):userId 是归档用的物理来源标记
197
- //(authority 查不到 `parent:*`,等价匿名),授权身份走 actor——子任务工具以创建者的
198
- // 权限等级执行。优先 callCtx.actor(链式),否则物理身份;匿名则子任务同样匿名。
199
- const createActor = callCtx.actor ??
200
- (callCtx.platform && callCtx.userId ? { platform: callCtx.platform, userId: callCtx.userId } : undefined);
201
- if (createActor)
202
- incoming.actor = createActor;
203
- events.use.emit('inbound:message', incoming).catch(err => {
366
+ events.use.emit('inbound:message', taskMessage(callCtx, child.id, task)).catch(err => {
204
367
  logger.use.warn(`子任务消息派发失败 (${child.id}):`, err);
205
368
  });
369
+ const name = nameOf(child);
206
370
  return JSON.stringify({
207
371
  subtaskId: child.id,
208
- name: taskName,
372
+ name,
209
373
  status: 'active',
210
- message: `子任务 "${taskName}" 已创建并开始执行`,
374
+ message: `子任务 "${name}" 已创建并开始执行`,
211
375
  });
212
376
  }
213
377
  catch (err) {
@@ -218,6 +382,8 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
218
382
  // ---- check_subtask ----
219
383
  tools.use.register({
220
384
  groups: ['subtask'],
385
+ // 只看不改:2 级、不确认,低于 2 级的看不到别人派出的子任务的任务与结果
386
+ visibility: 'restricted',
221
387
  definition: {
222
388
  type: 'function',
223
389
  function: {
@@ -225,7 +391,8 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
225
391
  description: [
226
392
  '查询一个或多个子任务的当前状态和结果。',
227
393
  '返回每个子任务的状态(active=进行中, completed=已完成, error=出错, stopped=已停止)、结果摘要等;出错的带 reason(出错原因)。',
228
- '如果有子任务状态为 error,可使用 send_to_subtask 重新发送指令让其重试。',
394
+ '如果有子任务状态为 error,可在它销毁之前用 send_to_subtask 重新发送指令让其重试,已完成或出错的会重新进入进行中。',
395
+ '子任务交回结果后闲置一段时间没人追问就会销毁:销毁之后这里回它最后的状态与结果一次(destroyed 为 true),再查就是不存在,要接着做得重新创建。',
229
396
  ].join('\n'),
230
397
  parameters: {
231
398
  type: 'object',
@@ -241,7 +408,7 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
241
408
  },
242
409
  },
243
410
  },
244
- handler: async (args) => {
411
+ handler: async (args, callCtx) => {
245
412
  if (!sessionManager.present)
246
413
  return JSON.stringify({ error: 'session-manager 服务不可用' });
247
414
  const sm = sessionManager.use;
@@ -251,21 +418,25 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
251
418
  error: '缺少 subtask_ids 参数。正确用法: { "subtask_ids": ["<子任务ID1>", "<子任务ID2>"] }',
252
419
  });
253
420
  }
254
- const results = ids.map(id => {
255
- const session = sm.getSession(id);
256
- if (!session)
257
- return { id, error: '会话不存在' };
258
- const status = subtaskStatus(session);
259
- 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({
260
430
  id,
261
- name: session.name,
262
- status,
263
- statusLabel: STATUS_LABELS[status] ?? status,
264
- reason: errorReason(session, status),
265
- result: session.result || null,
266
- inputContext: session.inputContext || null,
267
- };
268
- });
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
+ }
269
440
  const summary = {
270
441
  total: results.length,
271
442
  active: results.filter(r => !('error' in r) && r.status === 'active').length,
@@ -279,8 +450,8 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
279
450
  // ---- send_to_subtask ----
280
451
  tools.use.register({
281
452
  groups: ['subtask'],
282
- // 向子任务追加指令 = 驱动其再跑一轮 LLM,同 create_subtask 的开销放大面。
283
- risk: 'sensitive',
453
+ // 追问让子任务再跑一轮:与 create_subtask 同一规则
454
+ risk: 'dangerous',
284
455
  definition: {
285
456
  type: 'function',
286
457
  function: {
@@ -288,7 +459,7 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
288
459
  description: [
289
460
  '向子任务会话发送追加消息(追问、补充指令等)。',
290
461
  '消息将作为父会话用户身份注入子任务,触发子任务 agent 继续处理。',
291
- '子任务如已完成,将被重新激活为 active 状态。',
462
+ '子任务如已完成或出错,将被重新激活为 active 状态,闲置计时从这一轮结束重新算;已销毁的子任务要重新创建。',
292
463
  ].join('\n'),
293
464
  parameters: {
294
465
  type: 'object',
@@ -315,57 +486,92 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
315
486
  const message = String(args.message || '');
316
487
  if (!subtaskId || !message)
317
488
  return JSON.stringify({ error: '缺少子任务 ID 或消息内容' });
318
- const session = sm.getSession(subtaskId);
489
+ if (!gateway.present)
490
+ return JSON.stringify({ error: '消息网关不可用,追问发不出' });
491
+ // 只向直接子任务发:防越权向任意会话注入消息、重激活已完成的会话
492
+ const session = directChild(sm, subtaskId, callCtx.sessionId);
319
493
  if (!session)
320
- return JSON.stringify({ error: `子任务不存在: ${subtaskId}` });
321
- // 归属校验:只能向本会话的直接子任务发消息,防越权向任意会话注入消息 / 重激活已完成会话
322
- //(与 delete_subtask 一致)。
323
- if (session.parentId !== callCtx.sessionId) {
324
- return JSON.stringify({
325
- error: `无权操作该子任务:${subtaskId} 不是当前会话 (${callCtx.sessionId}) 的子任务 (parentId=${session.parentId})。`,
326
- });
327
- }
328
- // 如果子任务已完成,重新激活
329
- if (session.status === 'completed') {
494
+ return JSON.stringify({ error: `${NOT_FOUND}: ${subtaskId}` });
495
+ // 已完成或出错的重新激活;归档的不翻(与回合开始时同一口径)
496
+ if (session.status === 'completed' || session.status === 'error') {
330
497
  await sm.updateSession(subtaskId, { status: 'active' });
331
498
  }
332
- // 派发消息
333
- const followUp = {
334
- content: message,
335
- sessionId: subtaskId,
336
- platform: callCtx.platform || 'internal',
337
- userId: `parent:${callCtx.sessionId}`,
338
- nickname: undefined,
339
- source: SUBTASK_SOURCE,
340
- };
341
- // 授权身份透传:与 create_subtask 同源。缺了这一处会造成同一子任务会话的权限
342
- // 在轮次间跳变——创建轮有创建者等级、追问轮掉回匿名(2026-08-24 审计)。
343
- const followUpActor = callCtx.actor ??
344
- (callCtx.platform && callCtx.userId ? { platform: callCtx.platform, userId: callCtx.userId } : undefined);
345
- if (followUpActor)
346
- followUp.actor = followUpActor;
347
- events.use.emit('inbound:message', followUp).catch(err => {
348
- logger.use.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);
349
509
  });
350
510
  return JSON.stringify({
351
511
  subtaskId,
352
512
  sent: true,
353
- message: `消息已发送到子任务 "${session.name}"`,
513
+ message: `消息已发送到子任务 "${nameOf(session)}"`,
354
514
  });
355
515
  },
356
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
+ });
357
563
  // ---- delete_subtask ----
358
564
  tools.use.register({
359
565
  groups: ['subtask'],
360
- // 销毁子任务会话(含其未完成工作)——破坏性且影响他人发起的任务。
361
- risk: 'sensitive',
566
+ // 销毁子任务会话连同它没做完的事:与 create_subtask 同一规则
567
+ risk: 'dangerous',
362
568
  definition: {
363
569
  type: 'function',
364
570
  function: {
365
571
  name: 'delete_subtask',
366
572
  description: [
367
573
  '删除指定的子任务会话及其所有子会话(递归删除)。',
368
- '只能删除当前会话的直接或间接子任务,不能删除根会话或其他会话的子任务。',
574
+ '只能删除当前会话的直接子任务,不能删除根会话或其他会话的子任务。',
369
575
  '删除后会话的消息历史也会被一并清除。',
370
576
  ].join('\n'),
371
577
  parameters: {
@@ -388,18 +594,11 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
388
594
  const subtaskId = String(args.subtask_id || '').trim();
389
595
  if (!subtaskId)
390
596
  return JSON.stringify({ error: '缺少子任务 ID' });
391
- const session = sm.getSession(subtaskId);
597
+ const session = directChild(sm, subtaskId, callCtx.sessionId);
392
598
  if (!session)
393
- return JSON.stringify({ error: `会话不存在: ${subtaskId}`, notFound: true });
394
- // 安全检查:只能删除当前会话的子任务
395
- if (session.parentId !== callCtx.sessionId) {
396
- return JSON.stringify({
397
- error: `无权删除该会话。当前会话 (${callCtx.sessionId}) 不是该会话的父会话 (parentId=${session.parentId})。`,
398
- denied: true,
399
- });
400
- }
599
+ return JSON.stringify({ error: `${NOT_FOUND}: ${subtaskId}` });
401
600
  try {
402
- const name = session.name;
601
+ const name = nameOf(session);
403
602
  await sm.deleteSession(subtaskId);
404
603
  return JSON.stringify({
405
604
  subtaskId,
@@ -416,6 +615,8 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
416
615
  // ---- wait_subtasks ----
417
616
  tools.use.register({
418
617
  groups: ['subtask'],
618
+ // 只看不改:同 check_subtask
619
+ visibility: 'restricted',
419
620
  definition: {
420
621
  type: 'function',
421
622
  function: {
@@ -427,7 +628,8 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
427
628
  '【必须调用】创建子任务后,必须调用此工具等待所有子任务完成。',
428
629
  '不等待就继续主任务会导致:子任务结果丢失、文档内容不完整、最终输出质量差。',
429
630
  '调用后请检查返回的 allCompleted 字段和每个子任务的 result,确认所有子任务均成功完成。',
430
- '如有子任务失败或超时,可使用 send_to_subtask 补充指令后再次等待。',
631
+ '如有子任务失败或超时,可在它销毁之前使用 send_to_subtask 补充指令后再次等待,已完成或出错的会重新进入进行中。',
632
+ '交回结果后闲置到期而销毁的子任务,这里回它最后的状态与结果一次(destroyed 为 true),之后就是不存在,要重新创建。',
431
633
  ].join('\n'),
432
634
  parameters: {
433
635
  type: 'object',
@@ -447,7 +649,7 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
447
649
  },
448
650
  },
449
651
  },
450
- handler: async (args, _callCtx) => {
652
+ handler: async (args, callCtx) => {
451
653
  if (!sessionManager.present)
452
654
  return JSON.stringify({ error: 'session-manager 服务不可用' });
453
655
  const sm = sessionManager.use;
@@ -459,61 +661,62 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
459
661
  }
460
662
  const timeoutSec = Number(args.timeout_seconds) || cfg.maxWaitMs / 1000;
461
663
  const timeoutMs = Math.min(timeoutSec * 1000, cfg.maxWaitMs);
462
- // 先检查是否已经全部完成(避免不必要的等待)
664
+ // 先检查是否已经全部结束(避免不必要的等待);不是本会话直接子任务的不等
463
665
  const isTerminal = (id) => {
464
- const s = sm.getSession(id);
465
- 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';
466
669
  };
467
- if (!ids.every(isTerminal)) {
468
- // 使用事件驱动 + 超时,替代轮询
670
+ const { signal } = callCtx;
671
+ if (!ids.every(isTerminal) && !signal?.aborted) {
672
+ // 事件驱动 + 超时,替代轮询:会话完成、状态变了、被删(结果里按不存在回)或追问落定时重判;调用被中止(这一轮被停止键、
673
+ // 禁言、/clear 等停下)时不再等,这一轮才落定得了
469
674
  await new Promise(resolve => {
470
675
  const pending = new Set(ids.filter(id => !isTerminal(id)));
471
- const cleanup = () => {
676
+ const disposers = [];
677
+ const finish = () => {
472
678
  clearTimeout(timer);
473
- disposeCompleted();
474
- disposeUpdated();
475
- };
476
- // 监听 session:completed 事件
477
- const disposeCompleted = events.use.on('session:completed', (session) => {
478
- pending.delete(session.id);
479
- if (pending.size === 0) {
480
- cleanup();
481
- resolve();
482
- }
483
- });
484
- // 兜底:也监听 session:updated 以防状态通过 updateSession 变更
485
- const disposeUpdated = events.use.on('session:updated', (session) => {
486
- if ((session.status === 'completed' || session.status === 'error') && pending.has(session.id)) {
487
- pending.delete(session.id);
488
- if (pending.size === 0) {
489
- cleanup();
490
- resolve();
491
- }
492
- }
493
- });
494
- // 超时保护
495
- const timer = setTimeout(() => {
496
- cleanup();
679
+ for (const dispose of disposers)
680
+ dispose();
681
+ onFollowUpSettled.delete(recheck);
682
+ signal?.removeEventListener('abort', finish);
497
683
  resolve();
498
- }, 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 });
499
696
  });
500
697
  }
501
- // 收集结果
502
- const results = ids.map(id => {
503
- const session = sm.getSession(id);
504
- if (!session)
505
- return { id, error: '会话不存在' };
506
- const status = subtaskStatus(session);
507
- 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({
508
710
  id,
509
- name: session.name,
510
- status,
511
- reason: errorReason(session, status),
512
- inputContext: session.inputContext || null,
513
- result: session.result || null,
514
- completed: status === 'completed',
515
- };
516
- });
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
+ }
517
720
  const allCompleted = results.every(r => 'completed' in r && r.completed);
518
721
  // 出错、被停止的已经结束,不算超时:只有等满时还没结束的才算
519
722
  const timedOut = !ids.every(isTerminal);
@@ -560,7 +763,7 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
560
763
  const subtaskContext = [
561
764
  `\n\n--- 子任务上下文 ---`,
562
765
  `你正在一个子任务会话中执行独立任务。`,
563
- `子任务名称: ${session.name}`,
766
+ `子任务名称: ${nameOf(session)}`,
564
767
  session.inputContext ? `任务指令: ${session.inputContext}` : '',
565
768
  '',
566
769
  '回答规则:',
@@ -568,11 +771,9 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
568
771
  '- 系统会自动将你的最终回复作为任务结果报告给父会话',
569
772
  '- 请给出完整、有价值的回答,因为你的回复内容将直接成为子任务结果',
570
773
  '',
571
- '共享资源规则:',
572
- '- 如果任务指令中包含 docId(如 doc-xxxxxxxx),直接使用该 ID 操作文档,不要创建新文档',
573
- '- 严格只操作你负责的范围(如指定的幻灯片编号、工作表等),不要越界修改其他部分',
574
- '- 完成后请在回复中汇报你做了什么(如添加了哪些幻灯片、内容摘要等),方便父会话整合',
575
- '- 不要调用 save 操作——由父会话统一保存',
774
+ '协作规则:',
775
+ '- 严格只操作你负责的范围(如指定的部分),不要越界修改其他部分',
776
+ '- 完成后请在回复中汇报你做了什么(如做了哪些改动、内容摘要等),方便父会话整合',
576
777
  '--- 子任务上下文结束 ---',
577
778
  ]
578
779
  .filter(Boolean)
@@ -594,9 +795,12 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
594
795
  // 工具循环中 wait_subtasks 返回后,下一轮 LLM 看到的是刷新过的状态而非首轮快照
595
796
  // (这也是它不走 agent:prompt 贡献点的原因:贡献按全局键幂等,物化一次后同回合
596
797
  // 不再刷新)。
798
+ // 子会话销毁之后,它的回报在父会话记录上:也要列出,否则最后一个子会话销毁后回报就没人列
597
799
  if (!session?.parentId && session) {
598
800
  const children = session.children;
599
- 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) {
600
804
  const activeChildren = [];
601
805
  const completedChildren = [];
602
806
  const errorChildren = [];
@@ -605,21 +809,21 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
605
809
  const child = sm.getSession(childId);
606
810
  if (!child)
607
811
  continue;
608
- const status = subtaskStatus(child);
812
+ const status = statusOf(child);
609
813
  if (status === 'active') {
610
- activeChildren.push(` - [进行中] ${child.name} (ID: ${childId})`);
814
+ activeChildren.push(` - [进行中] ${nameOf(child)} (ID: ${childId})`);
611
815
  }
612
816
  else if (status === 'completed') {
613
817
  const resultPreview = child.result ?? '(无结果)';
614
- completedChildren.push(` - [已完成] ${child.name} (ID: ${childId}): ${resultPreview}`);
818
+ completedChildren.push(` - [已完成] ${nameOf(child)} (ID: ${childId}): ${resultPreview}`);
615
819
  }
616
820
  else if (status === 'error') {
617
821
  // 不取 result:出错时它若有值,是此前某一轮回复收口时留下的结果,不是这次出错的详情
618
- const reason = child.lastTurn?.error ?? '(无错误详情)';
619
- errorChildren.push(` - [出错] ${child.name} (ID: ${childId}): ${reason}`);
822
+ const reason = toSource(child.lastTurn?.error) ?? '(无错误详情)';
823
+ errorChildren.push(` - [出错] ${nameOf(child)} (ID: ${childId}): ${reason}`);
620
824
  }
621
825
  else if (status === 'stopped') {
622
- stoppedChildren.push(` - [已停止] ${child.name} (ID: ${childId})`);
826
+ stoppedChildren.push(` - [已停止] ${nameOf(child)} (ID: ${childId})`);
623
827
  }
624
828
  }
625
829
  // 每轮重注:先摘上一轮的提醒消息(按 injector 识别,替代旧的字符串手术)
@@ -630,7 +834,8 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
630
834
  if (activeChildren.length > 0 ||
631
835
  completedChildren.length > 0 ||
632
836
  errorChildren.length > 0 ||
633
- stoppedChildren.length > 0) {
837
+ stoppedChildren.length > 0 ||
838
+ destroyed.length > 0) {
634
839
  const lines = [PARENT_CONTEXT_MARKER];
635
840
  if (activeChildren.length > 0) {
636
841
  lines.push(`⚠️ 你有 ${activeChildren.length} 个子任务正在执行:`);
@@ -642,7 +847,7 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
642
847
  lines.push(`❌ 以下子任务执行出错:`);
643
848
  lines.push(...errorChildren);
644
849
  lines.push('');
645
- lines.push('可使用 send_to_subtask 向出错的子任务发送修正指令让其重试,或使用 check_subtask 查看详细状态。');
850
+ lines.push('可在销毁之前使用 send_to_subtask 向出错的子任务发送修正指令让其重试(已完成或出错的会重新进入进行中),或使用 check_subtask 查看详细状态;销毁了的要重新创建。');
646
851
  }
647
852
  if (stoppedChildren.length > 0) {
648
853
  lines.push(`以下子任务已被停止:`);
@@ -652,6 +857,21 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
652
857
  lines.push(`以下子任务已完成,请检查结果:`);
653
858
  lines.push(...completedChildren);
654
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
+ }
655
875
  lines.push('--- 活跃子任务提醒结束 ---');
656
876
  // 首轮(最后一条是当前 user 消息)插它前面;工具轮/proactive 轮(尾部是
657
877
  // tool/assistant/system)追加到末尾 = 决策点前,状态最新鲜的位置
@@ -682,6 +902,10 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
682
902
  return; // 非子任务
683
903
  if (session.status === 'completed' || session.status === 'error')
684
904
  return; // 已完成
905
+ // 还有不在这一轮里的追问没落定(追问开的一轮只有它自己那一条):这一轮的回复不交回、不完成,等追问那一轮
906
+ const own = followUpTurn.getStore();
907
+ if ((followUps.get(data.sessionId) ?? 0) > (own && live.has(own) ? 1 : 0))
908
+ return;
685
909
  const result = data.reply;
686
910
  if (!result || result.trim().length === 0)
687
911
  return;
@@ -690,6 +914,8 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
690
914
  const archive = messageArchive.present ? messageArchive.use : undefined;
691
915
  if (archive) {
692
916
  const syntheticToolCallId = `report-${Date.now()}`;
917
+ // 两条都带这一轮的回合 id:连对话回滚这一轮时随它删
918
+ const metadata = { [WellKnownMetadataKeys.TurnId]: data.turnId };
693
919
  // 合成 assistant 消息(含 tool_calls)
694
920
  await archive.saveMessage(data.sessionId, {
695
921
  role: 'assistant',
@@ -705,6 +931,7 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
705
931
  },
706
932
  ],
707
933
  timestamp: Date.now(),
934
+ metadata,
708
935
  });
709
936
  // 合成 tool 结果消息
710
937
  await archive.saveMessage(data.sessionId, {
@@ -712,15 +939,179 @@ function registerSubtask({ tools, events, hooks, logger, sessionManager, message
712
939
  content: JSON.stringify({ success: true, message: '结果已报告给父会话' }),
713
940
  toolCallId: syntheticToolCallId,
714
941
  timestamp: Date.now(),
942
+ metadata,
715
943
  });
716
944
  }
717
945
  await sm.completeSession(data.sessionId, result.trim());
718
- logger.use.info(`子任务自动完成: ${session.name} (${data.sessionId})`);
946
+ logger.use.info(`子任务自动完成: ${nameOf(session)} (${data.sessionId})`);
719
947
  }
720
948
  catch (err) {
721
949
  logger.use.warn(`子任务自动完成失败 (${data.sessionId}):`, err);
722
950
  }
723
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);
1027
+ }
1028
+ catch (err) {
1029
+ logger.use.warn(`删除已列出的子任务回报失败 (${sessionId}):`, err);
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
+ };
1114
+ });
724
1115
  logger.use.info('子任务工具已注册');
725
1116
  }
726
1117
  //# sourceMappingURL=index.js.map