@zmzai/agent-framework 0.5.2 → 0.9.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.
Files changed (88) hide show
  1. package/dist/core/events/bus.js +3 -3
  2. package/dist/core/events/bus.js.map +1 -1
  3. package/dist/core/events/manifest.d.ts +178 -0
  4. package/dist/core/events/manifest.d.ts.map +1 -1
  5. package/dist/core/events/manifest.js +106 -0
  6. package/dist/core/events/manifest.js.map +1 -1
  7. package/dist/core/events/sqlite-event-log.js +3 -3
  8. package/dist/core/events/sqlite-event-log.js.map +1 -1
  9. package/dist/core/runtime/attachments.d.ts +110 -0
  10. package/dist/core/runtime/attachments.d.ts.map +1 -1
  11. package/dist/core/runtime/attachments.js +124 -0
  12. package/dist/core/runtime/attachments.js.map +1 -1
  13. package/dist/core/runtime/extraction.d.ts +117 -0
  14. package/dist/core/runtime/extraction.d.ts.map +1 -0
  15. package/dist/core/runtime/extraction.js +256 -0
  16. package/dist/core/runtime/extraction.js.map +1 -0
  17. package/dist/core/runtime/lease-recovery.d.ts.map +1 -1
  18. package/dist/core/runtime/lease-recovery.js +50 -0
  19. package/dist/core/runtime/lease-recovery.js.map +1 -1
  20. package/dist/core/runtime/pi-bridge.d.ts +1 -1
  21. package/dist/core/runtime/pi-bridge.d.ts.map +1 -1
  22. package/dist/core/runtime/pi-bridge.js +19 -1
  23. package/dist/core/runtime/pi-bridge.js.map +1 -1
  24. package/dist/core/runtime/runner.d.ts +174 -0
  25. package/dist/core/runtime/runner.d.ts.map +1 -1
  26. package/dist/core/runtime/runner.js +795 -34
  27. package/dist/core/runtime/runner.js.map +1 -1
  28. package/dist/core/session/jsonl-store.d.ts.map +1 -1
  29. package/dist/core/session/jsonl-store.js +77 -0
  30. package/dist/core/session/jsonl-store.js.map +1 -1
  31. package/dist/core/session/sqlite-store.d.ts.map +1 -1
  32. package/dist/core/session/sqlite-store.js +87 -3
  33. package/dist/core/session/sqlite-store.js.map +1 -1
  34. package/dist/core/session/store.d.ts +6 -0
  35. package/dist/core/session/store.d.ts.map +1 -1
  36. package/dist/core/session/types.d.ts +30 -13
  37. package/dist/core/session/types.d.ts.map +1 -1
  38. package/dist/core/session/workflow.d.ts +30 -1
  39. package/dist/core/session/workflow.d.ts.map +1 -1
  40. package/dist/core/session/workflow.js.map +1 -1
  41. package/dist/core/task/completion.d.ts +142 -0
  42. package/dist/core/task/completion.d.ts.map +1 -0
  43. package/dist/core/task/completion.js +273 -0
  44. package/dist/core/task/completion.js.map +1 -0
  45. package/dist/core/task/contract.d.ts +26 -0
  46. package/dist/core/task/contract.d.ts.map +1 -0
  47. package/dist/core/task/contract.js +103 -0
  48. package/dist/core/task/contract.js.map +1 -0
  49. package/dist/core/task/index.d.ts +20 -0
  50. package/dist/core/task/index.d.ts.map +1 -0
  51. package/dist/core/task/index.js +16 -0
  52. package/dist/core/task/index.js.map +1 -0
  53. package/dist/core/task/plan.d.ts +121 -0
  54. package/dist/core/task/plan.d.ts.map +1 -0
  55. package/dist/core/task/plan.js +216 -0
  56. package/dist/core/task/plan.js.map +1 -0
  57. package/dist/core/task/progress.d.ts +43 -0
  58. package/dist/core/task/progress.d.ts.map +1 -0
  59. package/dist/core/task/progress.js +66 -0
  60. package/dist/core/task/progress.js.map +1 -0
  61. package/dist/core/task/store.d.ts +36 -0
  62. package/dist/core/task/store.d.ts.map +1 -0
  63. package/dist/core/task/store.js +104 -0
  64. package/dist/core/task/store.js.map +1 -0
  65. package/dist/core/task/types.d.ts +156 -0
  66. package/dist/core/task/types.d.ts.map +1 -0
  67. package/dist/core/task/types.js +26 -0
  68. package/dist/core/task/types.js.map +1 -0
  69. package/dist/core/tools/attachments.d.ts +4 -0
  70. package/dist/core/tools/attachments.d.ts.map +1 -0
  71. package/dist/core/tools/attachments.js +195 -0
  72. package/dist/core/tools/attachments.js.map +1 -0
  73. package/dist/core/tools/builtins.d.ts.map +1 -1
  74. package/dist/core/tools/builtins.js +6 -1
  75. package/dist/core/tools/builtins.js.map +1 -1
  76. package/dist/core/tools/task-block.d.ts +44 -0
  77. package/dist/core/tools/task-block.d.ts.map +1 -0
  78. package/dist/core/tools/task-block.js +76 -0
  79. package/dist/core/tools/task-block.js.map +1 -0
  80. package/dist/core/tools/task-deliver.d.ts +55 -0
  81. package/dist/core/tools/task-deliver.d.ts.map +1 -0
  82. package/dist/core/tools/task-deliver.js +98 -0
  83. package/dist/core/tools/task-deliver.js.map +1 -0
  84. package/dist/index.d.ts +8 -2
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +5 -1
  87. package/dist/index.js.map +1 -1
  88. package/package.json +11 -12
@@ -1,5 +1,5 @@
1
1
  import { Agent } from "@earendil-works/pi-agent-core";
2
- import { validateAttachments, attachmentContent } from "./attachments.js";
2
+ import { validateAttachments, validateAttachmentRefs, attachmentContent, attachmentRefContent, } from "./attachments.js";
3
3
  import { leaseDurationMs } from "../../adapters/index.js";
4
4
  import { notifyEventLogListeners } from "../events/bus.js";
5
5
  import { PermissionEngine, RejectedError } from "../permission/engine.js";
@@ -10,10 +10,17 @@ import { newPartId, newSessionId } from "../session/ids.js";
10
10
  import { adaptAnyTool, permissionForCall } from "../tools/adapter.js";
11
11
  import { builtinTools } from "../tools/builtins.js";
12
12
  import { isExternalToolDef } from "../tools/def.js";
13
+ import { TASK_BLOCK_TOOL_ID, readTaskBlock } from "../tools/task-block.js";
14
+ import { TASK_DELIVER_TOOL_ID, readTaskDelivery } from "../tools/task-deliver.js";
13
15
  import { fireRunStart, firstToolBlock, fireAfterToolCall, fireRunEnd } from "./lifecycle.js";
14
16
  import { extractRunTranscript, RETRY_PLACEHOLDER_TEXT } from "./run-transcript.js";
15
17
  import { noopSandboxExecutor } from "../../adapters/index.js";
16
18
  import { randomUUID } from "node:crypto";
19
+ import { DEFAULT_MAX_ATTEMPTS, durationBudgetBlocker, evaluateTaskCompletion, lifecycleForBlocker, normalizeMaxDurationMs, } from "../task/completion.js";
20
+ import { advanceProgress } from "../task/progress.js";
21
+ import { appendEvidence, applyDelivery, evidenceKindForTool, projectTodos, pruneEvidenceRefs, } from "../task/plan.js";
22
+ import { fallbackResult, renderResult, taskContractText } from "../task/contract.js";
23
+ import { isTerminalStatus, isWaitingStatus } from "../task/types.js";
17
24
  /** 事件的 sessionId 提取:能自带的自带(message/part/session),其余
18
25
  * (message.part.delta 等)用发起 run 的会话 id 兜底。 */
19
26
  function sessionIdOf(event, fallbackSessionId) {
@@ -86,6 +93,27 @@ function defaultToolContext(input) {
86
93
  },
87
94
  };
88
95
  }
96
+ /** 用户明确放行时重置的三个保护计数(新消息恢复、以及 `resumeTask`)。
97
+ *
98
+ * 【为什么必须重置】不重置的话「继续」是个死按钮:因为 no_progress 停下来的任务
99
+ * 计数仍是 3,下一次判定立刻再停;因为轮数预算停下来的任务第 N+1 轮开头就超过
100
+ * 上限,一步都不会跑;时间预算同理——已经烧满一小时的 `activeMs` 会让放行后的
101
+ * 第一轮连起点都过不去。用户点「继续」就是明确授权再做一些,那一刻起保护阈值
102
+ * 应当重新计时——由人来决定要不要继续,正是这类保护的设计前提(规格 §10.1 的
103
+ * 三档策略本来就以「用户可以再来一轮」为前提)。
104
+ *
105
+ * 这不会让任务无限跑:每一次重置都需要一次显式的人工动作,不存在自触发路径。 */
106
+ const RESET_GUARDS_ON_RESUME = { noProgressCount: 0, attemptCount: 0, activeMs: 0 };
107
+ /** 人工放行时喂给模型的驱动文本(与 `continuation` 那条同源但不同话术)。
108
+ *
109
+ * 它同样**不落库**:`resumeTask` 不经过 `prompt()`,没有 message、没有 workflow
110
+ * receipt,`acceptedUserId` 为 undefined——但这里必须显式跳过投影,因为
111
+ * `acceptedUserId` 为空正是「投影一条用户消息」的默认路径(见 runLoop)。
112
+ * 用一个专门的标记区分,才不会把「继续」画成用户说过的话。 */
113
+ const RESUME_DRIVE_TEXT = "[继续执行任务] 用户已经核对过当前状态并授权继续。按上面的任务契约接着推进,不要从头再做一遍,也不要把控制权提前交回用户。";
114
+ /** 单次任务 Attempt 数的硬上限(规格 §10.1 的要求:宿主调不到「永不拦截」)。
115
+ * 64 轮远超任何正常任务,同时挡住 `maxAttempts: 1e9` 这种把保护关掉的写法。 */
116
+ const MAX_ATTEMPT_CEILING = 64;
89
117
  export class SessionRunner {
90
118
  deps;
91
119
  constructor(deps) {
@@ -93,35 +121,64 @@ export class SessionRunner {
93
121
  }
94
122
  draining = new Map();
95
123
  stopRequested = new Set();
124
+ /** 已被用户放行、等待驱动链接手推进的会话(见 `drain` 的第三条分支)。 */
125
+ resumeRequests = new Set();
96
126
  drain(sessionId) {
97
127
  if (this.draining.has(sessionId))
98
128
  return;
99
129
  const work = (async () => {
100
130
  while (true) {
101
131
  const job = await this.deps.store.workflow.claimPrompt(sessionId, `node:${process.pid}`);
102
- if (!job)
103
- return;
104
- let outcome = "recovery_required";
105
- try {
106
- if (this.stopRequested.has(sessionId)) {
107
- outcome = "cancelled";
108
- }
109
- else {
110
- const session = await this.deps.store.getSession(sessionId);
111
- if (!session) {
112
- outcome = "failed";
132
+ if (job) {
133
+ let outcome = "recovery_required";
134
+ try {
135
+ if (this.stopRequested.has(sessionId)) {
136
+ outcome = "cancelled";
113
137
  }
114
138
  else {
115
- outcome = await this.runLoop(session, job.input, job.receipt.userMessageId);
139
+ const session = await this.deps.store.getSession(sessionId);
140
+ if (!session) {
141
+ outcome = "failed";
142
+ }
143
+ else {
144
+ // 走任务层:一次 claim 之后可能跑多轮 Attempt(规格 §8.2)
145
+ outcome = await this.runTask(session, job.input, job.receipt.userMessageId);
146
+ }
116
147
  }
117
148
  }
149
+ catch {
150
+ // Setup or settlement may have failed after a tool ran. Do not replay.
151
+ }
152
+ try {
153
+ await this.deps.store.workflow.finishPrompt(sessionId, job.receipt.runId, job.revision, outcome);
154
+ }
155
+ catch (error) {
156
+ // revision 是这次 run 的所有权凭据。冲突说明这次 run 的归属已经在
157
+ // 别处被改写过——最典型的就是进程崩溃后恢复扫描把它标成
158
+ // `recovery_required`,而这一轮结算才姗姗来迟。那时这条结论不该由
159
+ // 我们写(恢复扫描已经给出了它的判断),静默退出即可。把冲突往上抛
160
+ // 只会炸掉 `abort()`——它正 await 着这条驱动链。
161
+ if (!/RUN_REVISION_CONFLICT/.test(String(error?.message ?? "")))
162
+ throw error;
163
+ return;
164
+ }
165
+ if (outcome !== "completed")
166
+ return;
167
+ continue;
118
168
  }
119
- catch {
120
- // Setup or settlement may have failed after a tool ran. Do not replay.
121
- }
122
- await this.deps.store.workflow.finishPrompt(sessionId, job.receipt.runId, job.revision, outcome);
123
- if (outcome !== "completed")
169
+ // 队列里没有 run 了。还有第三种可能要推进:**用户刚按了「继续」**。
170
+ // 这类任务停下的原因是预算/无进展/等待,而不是「排队等认领」——它那次
171
+ // 的 workflow run 早已 `completed`,`claimPrompt` 永远取不到它。没有这条
172
+ // 分支,`resumeTask` 把任务放回 queued 之后就再没有东西会碰它,
173
+ // 每个 blocker 里写的那句「确认后可以继续」就是一句系统接不住的承诺。
174
+ if (!this.resumeRequests.delete(sessionId))
124
175
  return;
176
+ if (this.stopRequested.has(sessionId))
177
+ return;
178
+ const resumed = await this.deps.store.getSession(sessionId);
179
+ if (!resumed)
180
+ return;
181
+ await this.driveResumedTask(resumed);
125
182
  }
126
183
  })();
127
184
  this.draining.set(sessionId, work);
@@ -129,11 +186,40 @@ export class SessionRunner {
129
186
  this.draining.delete(sessionId);
130
187
  if (this.stopRequested.has(sessionId))
131
188
  return;
189
+ // 有放行请求就在原地接着驱动。必须在这里再查一次:`resumeTask` 是在
190
+ // `draining.delete` 之前判断「有没有人在跑」的,上面那个 while 也可能
191
+ // 刚刚判定退出——两件事都发生在微任务队列里,中间只差一次 await。
192
+ // 少了这一查,一次恰好落在收尾窗口里的「继续」会被静默丢掉。
193
+ if (this.resumeRequests.has(sessionId)) {
194
+ this.drain(sessionId);
195
+ return;
196
+ }
132
197
  const queued = await this.deps.store.workflow.workflowRuns(sessionId).catch(() => []);
133
198
  if (queued.some(run => run.status === "queued") && !queued.some(run => run.status === "running" || run.status === "recovery_required"))
134
199
  this.drain(sessionId);
135
200
  });
136
201
  }
202
+ /** 推进一个「用户已放行」的任务(规格 §11.2 / §13.2)。
203
+ *
204
+ * 不创建用户消息、不新建 workflow run:`resumeTask` 走的是 `runTask` 的直接
205
+ * 入口,绕过 `prompt()` 的整个提交链(投影消息 → resolveTaskForPrompt →
206
+ * acceptPrompt)。这条路径上没有任何东西会落成「用户说过的话」——
207
+ * `resume: true` 让 runLoop 连那条投影都跳过。
208
+ *
209
+ * 任务不处于「可推进」状态时什么都不做:用户可能连点两次,或者第二个请求
210
+ * 到达时第一轮已经把它跑完了。 */
211
+ async driveResumedTask(session) {
212
+ const store = this.deps.store.task;
213
+ if (!store)
214
+ return;
215
+ const task = await store.getActiveTask(session.id);
216
+ if (!task || isTerminalStatus(task.status) || isWaitingStatus(task.status))
217
+ return;
218
+ await this.runTask(session,
219
+ // text 留空:放行话术由 runLoop 按 `resume` 补充。这里塞文本会顺着
220
+ // `fireRunStart` 的 text 流进宿主的标题生成,把会话标题写成一句系统指令。
221
+ { requestId: task.rootRequestId, text: "", taskId: task.id, model: session.model, resume: true }, undefined);
222
+ }
137
223
  #hooks = [];
138
224
  /** 供 runLoop 取归一化钩子数组(lazy:constructor 后仍可由 deps 引用共享)。 */
139
225
  get hooks() {
@@ -184,8 +270,76 @@ export class SessionRunner {
184
270
  return;
185
271
  await this.deps.leaseStore.clear(sessionId).catch(() => undefined);
186
272
  }
273
+ /** 为一条新消息决定它归属哪个任务,以及这次提交的处置(规格 §12 / §13.1)。
274
+ *
275
+ * 四种处置的判定依据是**任务当前状态**,不是模型对文本的猜测:
276
+ * - 无活跃任务 → 开新任务;
277
+ * - 任务在 waiting_permission / waiting_input / waiting_external / blocked
278
+ * → 这条消息就是那个「用户动作」,任务恢复;
279
+ * - 任务在 running → 这条消息是补充/纠正,并入约束。
280
+ *
281
+ * 【为什么不做「这是不是无关新目标」的语义分类】那需要模型判断,代价是一次
282
+ * 额外的往返,而且判错的后果不对称:把无关目标误并进当前任务,用户会看到
283
+ * 自己的话被当成补充说明;反过来把补充说明误判成新任务,则会产生两个抢同一
284
+ * 个工作区的任务(规格 §18.9 禁止的情况)。所以默认并入,并由约束文本明确
285
+ * 标注「这是用户在你执行期间补充的」,让模型自己决定是否改变方向。 */
286
+ async resolveTaskForPrompt(session, input, userMessageId) {
287
+ const store = this.deps.store.task;
288
+ if (!store)
289
+ return null;
290
+ const requestId = input.requestId;
291
+ // 幂等:同一 requestId 重复提交返回同一任务,不创建第二个(§13.1 / §17.1.11)
292
+ // 重放时 disposition 描述的是「这条消息与任务的关系」,那是稳定的:
293
+ // 一条消息一旦开启过某个任务,它永远是那个任务的开启者。
294
+ const prior = await store.findTaskByRequestId(session.id, requestId);
295
+ if (prior)
296
+ return { task: prior, disposition: "task_started", startedTask: false, previous: null };
297
+ const active = await store.getActiveTask(session.id);
298
+ if (active) {
299
+ const resuming = isWaitingStatus(active.status);
300
+ const text = input.text.trim();
301
+ const constraints = text ? [...active.constraints, text].slice(-8) : active.constraints;
302
+ const task = await this.casTask(active, {
303
+ constraints,
304
+ // 恢复:把任务放回可被认领的状态,drain 会接着推进它
305
+ ...(resuming ? { status: "queued" } : {}),
306
+ ...(resuming ? { blocker: undefined } : {}),
307
+ ...(resuming ? RESET_GUARDS_ON_RESUME : {}),
308
+ });
309
+ return { task, disposition: resuming ? "task_resumed" : "task_steered", startedTask: false, previous: active };
310
+ }
311
+ const goal = input.text.trim().slice(0, 500) || "(未命名任务)";
312
+ const task = await store.createTask({
313
+ sessionId: session.id,
314
+ rootRequestId: requestId,
315
+ rootUserMessageId: userMessageId,
316
+ goal,
317
+ });
318
+ return { task, disposition: "task_started", startedTask: true, previous: null };
319
+ }
320
+ /** 提交被拒时把任务改回这一步之前的样子(见 `prompt` 里的调用点)。
321
+ *
322
+ * 新建的任务**不回滚**:它是这个 requestId 的幂等锚点,删掉会让「同一 requestId
323
+ * 重试」失去依据。留着它没有代价——下一次同 requestId 的提交会命中
324
+ * `findTaskByRequestId` 拿回同一个任务,别的消息则会被 `getActiveTask` 收编成
325
+ * steering。 */
326
+ async rollbackTaskResolution(resolution) {
327
+ if (!resolution?.previous)
328
+ return;
329
+ const { previous, task } = resolution;
330
+ await this.casTask(task, {
331
+ status: previous.status,
332
+ constraints: previous.constraints,
333
+ // 计数也要一起还原:恢复路径把它们清零了,而这次提交并没有发生。
334
+ noProgressCount: previous.noProgressCount,
335
+ attemptCount: previous.attemptCount,
336
+ activeMs: previous.activeMs ?? 0,
337
+ // 显式区分「清空 blocker」与「保持原样」:undefined 是清除指令
338
+ ...(previous.blocker ? { blocker: previous.blocker } : { blocker: undefined }),
339
+ }).catch(() => undefined);
340
+ }
187
341
  async prompt(sessionId, input) {
188
- input = { ...input, attachments: validateAttachments(input.attachments) };
342
+ input = { ...input, attachments: validateAttachments(input.attachments), attachmentRefs: validateAttachmentRefs(input.attachmentRefs) };
189
343
  const session = await this.deps.store.getSession(sessionId);
190
344
  if (!session)
191
345
  throw new Error("SESSION_NOT_FOUND");
@@ -196,18 +350,43 @@ export class SessionRunner {
196
350
  const message = projector.onUserPrompt(event => {
197
351
  if (event.type === "message.part.updated")
198
352
  parts.push(event.data.part);
199
- }, input.text, input.images, input.skill, input.references, input.attachments);
200
- const accepted = await this.deps.store.workflow.acceptPrompt(sessionId, input, { message, parts });
353
+ }, input.text, input.images, input.skill, input.references, input.attachments, input.attachmentRefs);
354
+ // 先建消息再建任务:TaskRecord.rootUserMessageId 需要真实的消息 id,
355
+ // 事后回填会多一次 CAS,也让「任务与首条消息同生」这条不变量出现空窗。
356
+ const resolution = await this.resolveTaskForPrompt(session, input, message.id);
357
+ if (resolution)
358
+ input = { ...input, taskId: resolution.task.id };
359
+ let accepted;
360
+ try {
361
+ accepted = await this.deps.store.workflow.acceptPrompt(sessionId, input, { message, parts });
362
+ }
363
+ catch (error) {
364
+ // 提交被拒(最常见的是 RECOVERY_REQUIRED)。必须把任务改回原样:
365
+ // resolveTaskForPrompt 可能已经清掉 blocker、把状态放回 queued,而这次
366
+ // prompt 根本没被接受——没被接受就没有任何 run 会去推进它,任务会停在
367
+ // queued 上永远等不到,而用户看到的只是一个 409。
368
+ await this.rollbackTaskResolution(resolution);
369
+ throw error;
370
+ }
201
371
  for (const event of accepted.events)
202
372
  notifyEventLogListeners(event);
373
+ if (resolution?.startedTask) {
374
+ const task = resolution.task;
375
+ await this.publish({ type: "task.started", data: { taskId: task.id, revision: task.revision, goal: task.goal, steps: [], acceptanceCriteria: task.acceptanceCriteria.map((criterion) => ({ id: criterion.id, description: criterion.description, required: criterion.required, status: criterion.status })) } }, sessionId);
376
+ }
203
377
  if (accepted.events.length)
204
378
  this.drain(sessionId);
205
- return accepted.receipt;
379
+ return {
380
+ ...accepted.receipt,
381
+ ...(resolution ? { disposition: resolution.disposition, taskId: resolution.task.id } : {}),
382
+ };
206
383
  }
207
384
  if (activeRuns.has(sessionId)) {
208
385
  await this.deps.store.enqueuePrompt(sessionId, {
209
386
  text: input.text,
210
387
  attachments: input.attachments,
388
+ // 排队消息必须记住附件引用:真正执行时要按 id 重新确认附件仍存在且可读(规格 2 §11)
389
+ ...(input.attachmentRefs?.length ? { attachmentRefs: [...input.attachmentRefs] } : {}),
211
390
  images: input.images,
212
391
  model: input.model,
213
392
  ...(input.agent ? { agent: input.agent } : {}),
@@ -227,6 +406,39 @@ export class SessionRunner {
227
406
  return false;
228
407
  return active.engine.reply(requestId, reply, feedback);
229
408
  }
409
+ /** 用户核对完之后让任务继续(规格 3 §11 / §13.2 的 `resume` 动作)。
410
+ *
411
+ * 【为什么必须有这条通路】每个 blocked / waiting 的 blocker 都写着一句
412
+ * `requiredAction`(「先核对工作区与外部系统的实际状态,再决定继续或重做」)。
413
+ * 如果产品只能靠「再发一条消息」来继续,那句话就是在要求用户做一件系统接不住
414
+ * 的事;而更糟的是崩溃恢复后的那种任务——会话里还有一条 `recovery_required`
415
+ * 的 run,发消息会被 409 挡住,用户根本无路可走。
416
+ *
417
+ * 三步:放掉「副作用未确认」这道闸 → 任务回到 queued 并清 blocker →
418
+ * 登记一次放行请求,交给驱动链(`drain`)推进。
419
+ * **不创建用户消息**(§11.2):这是同一条指令的继续,不是新的一轮对话。
420
+ * 返回 false 表示没什么可继续的(没有活跃任务、已终态、或本来就在正常跑,
421
+ * 规格 §13.2:正常运行中的 task 不需要「继续」)。
422
+ *
423
+ * 【为什么不能靠 `drain` 自己发现这件事】`drain` 认领的是队列里的 workflow
424
+ * run,而这类任务停下的原因是预算/无进展/等待,它那次 run 早就 `completed`
425
+ * 了——`claimPrompt` 取不到任何东西,任务会被放回 queued 而无人推进。所以
426
+ * 放行动作必须留下一个显式标记(`resumeRequests`),由驱动链认领。 */
427
+ async resumeTask(sessionId) {
428
+ const store = this.deps.store.task;
429
+ if (!store)
430
+ return false;
431
+ const task = await store.getActiveTask(sessionId);
432
+ if (!task || !isWaitingStatus(task.status))
433
+ return false;
434
+ // 闸先放:否则驱动链里的 claimPrompt 仍会因为 recovery_required 返回 null,
435
+ // 任务会被放回 queued 却没有任何东西去推进它。
436
+ await this.deps.store.workflow?.clearRecoveryRequired(sessionId).catch(() => 0);
437
+ await this.casTask(task, { status: "queued", blocker: undefined, ...RESET_GUARDS_ON_RESUME });
438
+ this.resumeRequests.add(sessionId);
439
+ this.drain(sessionId);
440
+ return true;
441
+ }
230
442
  /** Revokes matching session-scoped rules immediately for a live run and
231
443
  * removes their persisted continuation access. */
232
444
  async revokePermission(sessionId, permission, patterns) {
@@ -254,8 +466,31 @@ export class SessionRunner {
254
466
  await active.done;
255
467
  }
256
468
  await this.draining.get(sessionId);
469
+ await this.cancelResidualTask(sessionId);
470
+ // 停止会作废还没被认领的放行请求:留着它,驱动链下一次启动就会去推进一个
471
+ // 用户已经改主意(点了停止)的任务。
472
+ this.resumeRequests.delete(sessionId);
257
473
  this.stopRequested.delete(sessionId);
258
474
  }
475
+ /** 停止时把任务层也收干净(规格 3 §11「用户停止任务」)。
476
+ *
477
+ * 正在跑的 run 不用管:`RunOutcome.aborted` 会让 Completion Gate 判 `cancelled`,
478
+ * 任务由 `settleTask` 落终态。这里处理的是**没有活 run 却仍停在非终态**的任务——
479
+ * 上一步已经 `blocked` / `waiting_*`、用户此时点「停止」,没有任何运行在跑;
480
+ * 不补这一刀,任务会永远停在等待里,而界面上的「停止」看起来毫无作用。
481
+ *
482
+ * 位置刻意放在 `draining` 之后:先让结算路径写完自己的结论,避免两边各发一次
483
+ * `task.cancelled`(revision 白跳两次,客户端投影也会收到重复终态)。 */
484
+ async cancelResidualTask(sessionId) {
485
+ const store = this.deps.store.task;
486
+ if (!store)
487
+ return;
488
+ const task = await store.getActiveTask(sessionId);
489
+ if (!task || isTerminalStatus(task.status))
490
+ return;
491
+ const updated = await this.casTask(task, { status: "cancelled", blocker: undefined });
492
+ await this.publish({ type: "task.cancelled", data: { taskId: updated.id, revision: updated.revision, reason: "用户已停止任务。" } }, sessionId);
493
+ }
259
494
  /** 手动触发一次上下文压缩(UI「压缩当前会话」):无条件对当前历史跑一次
260
495
  * 摘要折叠,摘要通过 buildCompaction 的 onCompacted 落为 compaction part
261
496
  * 并发事件。与自动 compaction 共用同一套保护(膨胀拒绝/失败降级)。 */
@@ -355,7 +590,14 @@ export class SessionRunner {
355
590
  return null;
356
591
  }
357
592
  }
358
- async runLoop(session, input, acceptedUserId) {
593
+ /** 一次 Attempt:从模型上下文构造到终态收尾的完整内部运行。
594
+ *
595
+ * `taskContext` 存在时,任务契约会注入 systemPrompt(**不落成消息**,
596
+ * 规格 §19 禁止伪用户消息)。它同时携带 continuation 的 advisory,
597
+ * 保证「续跑指令」与「任务契约」在同一条系统指令里,不会互相矛盾。
598
+ * 它还携带 `loopGuard`:任务层自己持有一个跨 Attempt 的实例,避免每轮
599
+ * 重置循环防护的计数(见下方注释)。 */
600
+ async runLoop(session, input, acceptedUserId, taskContext) {
359
601
  const registry = await this.registryFor(session);
360
602
  const resolved = await this.resolvedAgentFor(session);
361
603
  const agentName = resolved ? resolved.agent.name : input.agent ?? session.agent;
@@ -373,14 +615,24 @@ export class SessionRunner {
373
615
  session = { ...session, model };
374
616
  }
375
617
  const agentRulesets = resolved ? [registry.rulesetsFor("default")[0], resolved.agent.permission] : registry.rulesetsFor(agentInfo?.name ?? "default");
618
+ /** 本轮模型最后投递的 todo 列表(步骤投影的输入)。 */
619
+ let latestTodos = null;
376
620
  const engine = new PermissionEngine(session.id, agentRulesets, session.permission, {
377
621
  onAsked: async (request) => {
378
622
  await this.publish({ type: "session.status", data: { status: "waiting_permission" } }, session.id);
379
623
  await this.publish({ type: "permission.asked", data: { request } }, session.id);
624
+ // 权限等待必须让任务层可见(规格 §11.1)。否则用户在盯着授权卡的时候,
625
+ // 任务状态还是「运行中」——「在跑」和「在等你点授权」对用户是两件事,
626
+ // 而任务 API 会给出一个错的答案。
627
+ await this.markPermissionWait(taskContext?.task.id, request.permission, request.patterns, true);
380
628
  },
381
629
  onReplied: async (request, reply) => {
382
630
  await this.publish({ type: "permission.replied", data: { id: request.id, reply } }, session.id);
383
631
  await this.publish({ type: "session.status", data: { status: "running" } }, session.id);
632
+ // 授权通过或拒绝都解除等待:规格 §11.3 要求把拒绝结果交回模型去试替代
633
+ // 方案,只有模型也没有替代方案时才判 blocked/failed。留在
634
+ // waiting_permission 会把「已经拒绝了」误报成「还在等你授权」。
635
+ await this.markPermissionWait(taskContext?.task.id, request.permission, request.patterns, false);
384
636
  },
385
637
  onSessionRuleAdded: async (sessionId, rule) => {
386
638
  const latest = await this.deps.store.getSession(sessionId);
@@ -392,7 +644,21 @@ export class SessionRunner {
392
644
  await this.deps.store.updateSession(sessionId, { permission: [...latest.permission, { ...rule, ...(expiresAt ? { expiresAt } : {}) }] });
393
645
  },
394
646
  });
647
+ /** 本轮事件的公共漏斗——**模型流通路和工具通路都必须经过它**。
648
+ *
649
+ * 【为什么必须是一个共享函数】曾经把 todo 捕获写在下面 `serializeEmit` 的
650
+ * 包装器里,注释还写着「事件流是所有路径的漏斗,不会漏」。那是错的:默认
651
+ * tool context 的 `setTodos` 拿到的 emit 直接调 `publish`,根本不经过包装器。
652
+ * 结果是 `latestTodos` 永远是 null,步骤数组永远为空,任务于是按「没有步骤」
653
+ * 的分支判定——模型刚说完一句试点性的开场白就被判成「已交付」。这类 bug
654
+ * 的危险之处在于它不报错,只是悄悄把完成判定放宽到形同虚设。 */
655
+ const observeRunEvent = async (event) => {
656
+ if (event.type === "todo.updated") {
657
+ latestTodos = event.data.todos.map((todo) => ({ content: todo.content, status: todo.status }));
658
+ }
659
+ };
395
660
  const { emit, settled } = serializeEmit(async (event) => {
661
+ await observeRunEvent(event);
396
662
  await this.persist(event, session.id);
397
663
  });
398
664
  const projector = new PartProjector({ sessionId: session.id, agent: agentInfo?.name ?? "default", model });
@@ -415,6 +681,7 @@ export class SessionRunner {
415
681
  const rawWorkspace = this.deps.workspaceFor(session);
416
682
  const workspace = session.writePaths?.length ? confineWorkspaceFiles(rawWorkspace, session.writePaths) : rawWorkspace;
417
683
  const emitAsync = async (event) => {
684
+ await observeRunEvent(event);
418
685
  await this.publish(event, session.id);
419
686
  };
420
687
  const toolContext = (this.deps.buildToolContext ?? defaultToolContext)({ session, engine, workspace, sandbox, emit: emitAsync });
@@ -425,6 +692,24 @@ export class SessionRunner {
425
692
  }
426
693
  const piTools = [...toolDefs.values()].map((def) => adaptAnyTool(def, toolContext));
427
694
  let unknownSideEffect = false;
695
+ let sideEffectDetail = null;
696
+ /** 以 error 结束的工具调用摘要(最多留最近三条)。
697
+ * 只用于续跑提示,**不构成阻塞**——工具失败后模型换条路走通是常态。 */
698
+ const toolErrors = [];
699
+ /** 本轮**自己的**最后一条 assistant 文本(交付文本的来源)。
700
+ *
701
+ * 【为什么不用「会话里最后一条」】续跑时那会取到上一轮的文本:一轮「我先看看
702
+ * 文件」之后,下一轮只用工具调完了全部步骤,交付卡上就会出现一句跟本次交付
703
+ * 无关、甚至自相矛盾的正文。连带 `finalTextPresent` 也会用陈旧文本为一次
704
+ * 没说话的运行开绿灯。这里只认本轮新增的消息。 */
705
+ let attemptFinalText = "";
706
+ /** 本轮的证据候选。在工具热路径上只累积内存,Attempt 结束时一次性投影进
707
+ * 任务并落库——否则每次工具调用都要 CAS 写一次任务表。 */
708
+ const evidenceCandidates = [];
709
+ /** 本轮模型用 `task_block` 声明的阻塞(规格 3 §11)。见 `RunOutcome.taskBlock`。 */
710
+ let taskBlock = null;
711
+ /** 本轮模型用 `task_deliver` 提交的交付声明(规格 3 §9 条件 6)。见 `RunOutcome.delivery`。 */
712
+ let delivery = null;
428
713
  const compactionTransform = await this.buildCompaction(session, emit);
429
714
  // 记忆召回(spec §记忆):单点注入 runLoop,天然覆盖正常 prompt/排队出
430
715
  // 队/automation/子代理触发四条路径。只进内存不落 store;抛错静默降级。
@@ -448,7 +733,14 @@ export class SessionRunner {
448
733
  const baseline = history.length;
449
734
  const agent = new Agent({
450
735
  initialState: {
451
- systemPrompt: [agentInfo?.prompt ?? "", mandatorySkillContext, input.references?.length ? `<attached-resources>\nThe user attached these workspace paths. Read the relevant ones before acting:\n${input.references.join("\n")}\n</attached-resources>` : ""].filter(Boolean).join("\n\n"),
736
+ systemPrompt: [
737
+ agentInfo?.prompt ?? "",
738
+ mandatorySkillContext,
739
+ // 任务契约(规格 3 §6 / §8.2):每个 Attempt 重新注入一次,因此
740
+ // 上下文被压缩掉也不影响任务目标的存续。续跑指令与它同源。
741
+ taskContext ? taskContractText(taskContext.task, taskContext.advisory) : "",
742
+ input.references?.length ? `<attached-resources>\nThe user attached these workspace paths. Read the relevant ones before acting:\n${input.references.join("\n")}\n</attached-resources>` : "",
743
+ ].filter(Boolean).join("\n\n"),
452
744
  model: this.deps.modelFor(model),
453
745
  // 推理力度(P1-8 复活):relay 现已按模型白名单接受 reasoning_effort;
454
746
  // 仅当调用方显式选择且非 off 时下发(默认不设 = 完全不带该字段)。
@@ -472,7 +764,11 @@ export class SessionRunner {
472
764
  if (this.stopRequested.has(session.id))
473
765
  abort();
474
766
  await this.stampLease(session.id);
475
- const loopGuard = new LoopGuard();
767
+ // 循环防护在任务层跨 Attempt 复用(规格 3 §16 阶段 D):一条任务会自己续跑
768
+ // 多轮,而「同一个工具一直以同样的方式失败」是不会因为换了一轮就消失的事实。
769
+ // 每轮新建一个 guard,会让上一轮的两次失败在这一轮从未发生,模型于是有机会
770
+ // 把同一个错误再撞一遍——续跑越多,这个洞越大。
771
+ const loopGuard = taskContext?.loopGuard ?? new LoopGuard();
476
772
  agent.beforeToolCall = async ({ toolCall, args }) => {
477
773
  if (unknownSideEffect) {
478
774
  return { block: true, reason: "上一个可能产生副作用的动作结果不确定,已暂停执行。请先确认外部系统状态,再决定继续或重试。", terminate: true };
@@ -535,12 +831,44 @@ export class SessionRunner {
535
831
  // 只碰工具结果,不碰 F6 模型级重试路径。
536
832
  agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
537
833
  const details = typeof result.details === "object" && result.details !== null ? result.details : null;
538
- if (details?.outcome === "unknown")
834
+ if (details?.outcome === "unknown") {
539
835
  unknownSideEffect = true;
836
+ sideEffectDetail ??= `${toolCall.name} 的结果不确定`;
837
+ }
540
838
  const resultText = (result.content ?? [])
541
839
  .filter((block) => block.type === "text")
542
840
  .map((block) => block.text)
543
841
  .join("\n");
842
+ // 证据采集(规格 3 §9 条件 3):只记**成功**的写 / 执行 / 外部核对类调用。
843
+ // 失败不是验证证据;读取类工具也不记(见 evidenceKindForTool 的说明)——
844
+ // 否则「有证据」会退化成「调过工具」。
845
+ if (isError) {
846
+ toolErrors.push(`${toolCall.name}: ${resultText.trim().slice(0, 160)}`);
847
+ if (toolErrors.length > 3)
848
+ toolErrors.shift();
849
+ }
850
+ else {
851
+ // 模型声明「需要用户介入」(规格 §11)。只认成功的调用:一次被拒绝或
852
+ // 崩溃的 task_block 没有资格把任务停下来等用户。
853
+ if (toolCall.name === TASK_BLOCK_TOOL_ID)
854
+ taskBlock = readTaskBlock(args);
855
+ // 模型声明「我交付了」(规格 §9 条件 6)。同样只认成功的调用——参数过不了
856
+ // schema 的交付声明必须当成「没有声明」,否则一次格式错误就能换来 delivered。
857
+ if (toolCall.name === TASK_DELIVER_TOOL_ID)
858
+ delivery = readTaskDelivery(args);
859
+ const kind = evidenceKindForTool(toolCall.name);
860
+ if (kind) {
861
+ const path = args?.path;
862
+ const program = args?.program;
863
+ const ref = typeof path === "string" ? path : typeof program === "string" ? program : undefined;
864
+ const title = typeof details?.title === "string" ? details.title : undefined;
865
+ evidenceCandidates.push({
866
+ kind,
867
+ summary: (title ?? resultText).trim().slice(0, 160) || toolCall.name,
868
+ ...(ref ? { ref } : {}),
869
+ });
870
+ }
871
+ }
544
872
  let advisory = loopGuard.onToolResult({ toolName: toolCall.name, isError, errorText: resultText });
545
873
  // 生命周期钩子(P0):只读观测,不阻塞结果路径
546
874
  void Promise.all(fireAfterToolCall(this.hooks, {
@@ -631,6 +959,9 @@ export class SessionRunner {
631
959
  abortController.abort();
632
960
  }, 15_000);
633
961
  let runErrored = false;
962
+ /** 本轮失败的错误消息。用来区分「上游抖动(可重试)」与「确定没救」——
963
+ * 两者在 Completion Gate 里走完全不同的分支(续跑 vs failed)。 */
964
+ let runErrorMessage = null;
634
965
  const runStartedAt = Date.now();
635
966
  // N6 长任务中途 checkpoint:运行超过阈值后周期性发布进度快照(已执行工具数 /
636
967
  // 最后一步工具 / 耗时),崩溃/中断后前端据此提示「上次进行到哪」。终态收尾仍
@@ -654,13 +985,26 @@ export class SessionRunner {
654
985
  await this.publish({ type: "session.status", data: { status: "running" } }, session.id);
655
986
  if (abortController.signal.aborted)
656
987
  throw new Error("Run cancelled during setup");
657
- if (!acceptedUserId)
658
- projector.onUserPrompt(emit, input.text, input.images, input.skill, input.references, input.attachments);
988
+ // `resume` 与 `acceptedUserId` 都表示「这一轮没有一个用户回合」:前者是用户
989
+ // 按了按钮,后者是任务自己接着跑。两条路径都不能投影用户消息(规格 §18.2)。
990
+ if (!acceptedUserId && !input.resume)
991
+ projector.onUserPrompt(emit, input.text, input.images, input.skill, input.references, input.attachments, input.attachmentRefs);
659
992
  const piImages = input.images?.map((img) => {
660
993
  const match = img.url.match(/^data:([^;]+);base64,(.+)$/);
661
994
  return match ? { type: "image", data: match[2], mimeType: match[1] } : null;
662
995
  }).filter((img) => img !== null);
663
- await agent.prompt({ role: "user", content: [{ type: "text", text: input.text }, ...attachmentContent(input.attachments ?? []), ...(piImages ?? [])], timestamp: Date.now() });
996
+ const attachmentParts = await attachmentRefContent(this.deps.attachments, input.attachmentRefs ?? [], { sessionId: session.id });
997
+ // 内部续跑喂给模型的驱动文本(规格 3 §8.2)。它**不落库**:上面那条
998
+ // `if (!acceptedUserId) projector.onUserPrompt(...)` 已经跳过,所以聊天
999
+ // 记录里看不到它(§18.2 验收要求「没有合成用户消息」)。但驱动模型本身
1000
+ // 必须有个输入——规格 §19 禁止的是把「继续」做成一条**持久化的伪用户
1001
+ // 消息**,不是禁止给模型一个继续的由头。
1002
+ const driveText = input.resume
1003
+ ? RESUME_DRIVE_TEXT
1004
+ : taskContext?.continuation
1005
+ ? "[继续执行任务] 上一轮结束时任务还没有完成。按上面的任务契约继续推进,不要中途把控制权交回用户。"
1006
+ : input.text;
1007
+ await agent.prompt({ role: "user", content: [{ type: "text", text: driveText }, ...attachmentContent(input.attachments ?? []), ...attachmentParts, ...(piImages ?? [])], timestamp: Date.now() });
664
1008
  await settled();
665
1009
  let failed = agent.state.errorMessage;
666
1010
  if (unknownSideEffect) {
@@ -696,6 +1040,7 @@ export class SessionRunner {
696
1040
  }
697
1041
  if (failed) {
698
1042
  runErrored = true;
1043
+ runErrorMessage = failed;
699
1044
  await this.publish({ type: "session.error", data: { name: "APIError", message: failed } }, session.id);
700
1045
  }
701
1046
  await this.publish({ type: "session.status", data: { status: "idle" } }, session.id);
@@ -704,8 +1049,10 @@ export class SessionRunner {
704
1049
  catch (error) {
705
1050
  await settled();
706
1051
  const aborted = abortController.signal.aborted;
707
- if (!aborted && !unknownSideEffect)
1052
+ if (!aborted && !unknownSideEffect) {
708
1053
  runErrored = true;
1054
+ runErrorMessage = error instanceof Error ? error.message : "Agent 运行失败";
1055
+ }
709
1056
  await this.publish(unknownSideEffect
710
1057
  ? { type: "session.status", data: { status: "waiting_input" } }
711
1058
  : aborted
@@ -723,6 +1070,7 @@ export class SessionRunner {
723
1070
  if (!acceptedUserId)
724
1071
  await this.clearLease(session.id);
725
1072
  const newMessages = extractRunTranscript(agent.state.messages, baseline);
1073
+ attemptFinalText = [...newMessages].reverse().find((message) => message.role === "assistant")?.text ?? "";
726
1074
  // 任务终态小结(N5):终态 status 已发布后补一条 session.summary,
727
1075
  // 让「任务完成」有明确收尾(AI 一句总结 + 结构化统计)。await 保证
728
1076
  // 落库后再 resolveDone(中断/失败场景不阻塞——内部有静默降级)。
@@ -737,7 +1085,24 @@ export class SessionRunner {
737
1085
  void Promise.all(fireRunEnd(this.hooks, { sessionId: session.id, agent: session.agent, ok: !runErrored, aborted: abortController.signal.aborted, workspaceId: session.workspaceId, newMessages }));
738
1086
  resolveDone();
739
1087
  }
740
- const outcome = unknownSideEffect ? "recovery_required" : abortController.signal.aborted ? "cancelled" : runErrored ? "failed" : "completed";
1088
+ const aborted = abortController.signal.aborted;
1089
+ const outcome = {
1090
+ state: unknownSideEffect ? "recovery_required" : aborted ? "cancelled" : runErrored ? "failed" : "completed",
1091
+ settled: !aborted && !runErrored && !unknownSideEffect,
1092
+ aborted,
1093
+ unknownSideEffect,
1094
+ sideEffectDetail,
1095
+ filesEdited: [...summaryEditedFiles],
1096
+ toolCalls: summaryToolCalls,
1097
+ durationMs: Date.now() - runStartedAt,
1098
+ toolErrors: [...toolErrors],
1099
+ finalText: attemptFinalText,
1100
+ todos: latestTodos,
1101
+ taskBlock,
1102
+ delivery,
1103
+ evidenceCandidates: [...evidenceCandidates],
1104
+ errorMessage: runErrorMessage,
1105
+ };
741
1106
  if (acceptedUserId || unknownSideEffect)
742
1107
  return outcome;
743
1108
  // FIFO queued prompts (spec §13.3): settle fully, then take the next one.
@@ -758,6 +1123,382 @@ export class SessionRunner {
758
1123
  }
759
1124
  return outcome;
760
1125
  }
1126
+ // ---- 持续任务执行(规格 3 §8)----------------------------------------------
1127
+ /** 带重试的 CAS 写入。
1128
+ *
1129
+ * 【为什么不「冲突就返回 latest」】那会让调用方以为补丁生效了,而库里没变——
1130
+ * 结算路径尤其致命:它会照常发出 `task.delivered`,于是客户端显示「已完成」
1131
+ * 而持久化状态还是 `running`,重启后任务像个卡住的僵尸。写不进去就必须知道。
1132
+ * 重试几次是给「恰好撞上另一个 drain 的收尾写」留余地;仍然失败就是真的有人
1133
+ * 在并发改同一个任务,那种情况必须浮出来(drain 会把它落成 recovery_required)。 */
1134
+ async casTask(task, patch) {
1135
+ const store = this.deps.store.task;
1136
+ let latest = task;
1137
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1138
+ try {
1139
+ return await store.updateTask(latest.id, latest.revision, patch);
1140
+ }
1141
+ catch (error) {
1142
+ if (!(error instanceof Error) || error.message !== "TASK_REVISION_CONFLICT")
1143
+ throw error;
1144
+ const fresh = await store.getTask(latest.id);
1145
+ if (!fresh)
1146
+ throw error;
1147
+ latest = fresh;
1148
+ }
1149
+ }
1150
+ throw new Error("TASK_REVISION_CONFLICT");
1151
+ }
1152
+ /** 权限等待的进入 / 退出(规格 3 §11.1 / §11.2)。
1153
+ *
1154
+ * 进入时把任务置 `waiting_permission` 并挂 permission blocker;退出(授权通过
1155
+ * 或被拒)时置回 `running` 并清 blocker。
1156
+ *
1157
+ * 【为什么退出后不是 blocked/failed】这次 Attempt 根本没被中断——`engine.ask()`
1158
+ * 只是在 `beforeToolCall` 里挂住,决定一到就接着跑同一个工具循环。所以既不发
1159
+ * `task.attempt.finished`,也不创建用户消息、不新建 workflow run(§11.2 明文)。
1160
+ * 唯一例外是被拒:拒绝结果会作为工具错误交回模型去试替代方案(§11.3)。
1161
+ *
1162
+ * 【为什么挂在引擎回调上,而不是 beforeToolCall 里】规则表已经允许的调用不会
1163
+ * 弹卡(`ask()` 在 undecided 为空时直接返回),挂在调用点会为这些「秒过」的
1164
+ * 调用也标一次等待再撤回,产生成对的假 blocker 事件。`onAsked` 只在真的抛出
1165
+ * 一张授权卡时触发。
1166
+ *
1167
+ * 【为什么按 taskId 重读而不是接一个快照】`runLoop` 会被子代理嵌套调用
1168
+ * (`spawnSubagent`),同一个 runner 实例上同时存在两轮运行。用实例字段记
1169
+ * 「当前任务」会被嵌套那轮清掉,父轮的权限等待于是静默丢失。按 id 重读没有
1170
+ * 这个共享状态,代价是一次读,而权限询问本来就不是热路径。 */
1171
+ async markPermissionWait(taskId, permission, patterns, waiting) {
1172
+ const store = this.deps.store.task;
1173
+ if (!taskId || !store)
1174
+ return;
1175
+ const blocker = {
1176
+ kind: "permission",
1177
+ message: `需要你授权才能执行:${permission}${patterns.length ? `(${patterns.join("、")})` : ""}`,
1178
+ requiredAction: "在授权卡片上选择允许或拒绝;任务会带着你的决定接着跑。",
1179
+ resumable: true,
1180
+ };
1181
+ try {
1182
+ const task = await store.getTask(taskId);
1183
+ if (!task || isTerminalStatus(task.status))
1184
+ return;
1185
+ const updated = await this.casTask(task, waiting ? { status: "waiting_permission", blocker } : { status: "running", blocker: undefined });
1186
+ await this.publish(waiting
1187
+ ? { type: "task.blocked", data: { taskId: updated.id, revision: updated.revision, blocker } }
1188
+ : // 协议里没有 task.resumed;`task.recovery.started` 是唯一表示「任务离开
1189
+ // 等待、重新开始推进」的事件,用它并让 message 说清是哪一种。
1190
+ { type: "task.recovery.started", data: { taskId: updated.id, revision: updated.revision, message: "授权已处理,继续执行。", attempt: Math.max(1, updated.attemptCount) } }, updated.sessionId);
1191
+ }
1192
+ catch {
1193
+ // 等待状态的记账失败不该打断一次正在跑的工具调用——权限本身已经问出去了,
1194
+ // 用户点了允许就该让工具跑。任务状态退化成「running」,是保守的那一侧。
1195
+ }
1196
+ }
1197
+ /** 完成判定用的运行时事实。全部来自本轮可观测结果,**不接受模型自述**。
1198
+ *
1199
+ * 权限等待不在此列:`beforeToolCall` 里的 `engine.ask()` 会一直挂到用户回复,
1200
+ * 所以一次 Attempt 收尾时不会有悬空请求(`engine.dispose()` 也会兜底)。
1201
+ * 这里仍读一次实际值,是为了覆盖 abort / 异常路径。
1202
+ *
1203
+ * 【`task_block` 是这条规则的例外吗】不是。它确实由模型发起,但**内容是结构化
1204
+ * 的声明**,不是自述的结论:模型说的是「我缺 X」「请你在 A 和 B 之间选」
1205
+ * 「你需要去登录」,而不是「我已完成」。前者是事实的输入(只有模型知道自己在
1206
+ * 等什么),后者才是不能采信的东西——完成与否始终由 Completion Gate 按步骤、
1207
+ * 验收条件与证据独立判定,模型无法用 task_block 换来一个 delivered。 */
1208
+ completionStateOf(outcome, session) {
1209
+ // 只有「重试已经耗尽、且错误不是上游抖动」才算真的没救(规格 §10.2)。
1210
+ // runLoop 内部已对可重试错误做过 5 次退避重试,能走到这里说明它没救回来。
1211
+ const fatal = outcome.state === "failed" && !!outcome.errorMessage && !isRetryableError(outcome.errorMessage);
1212
+ // `choice` 与 `input` 都落在 waiting_input,但文案与界面动作不同(§14.2),
1213
+ // 所以在这里分派而不是揉成一个字符串。
1214
+ const block = outcome.taskBlock;
1215
+ return {
1216
+ attemptSettled: outcome.settled,
1217
+ fatalError: fatal ? outcome.errorMessage : null,
1218
+ lastError: outcome.errorMessage,
1219
+ unknownSideEffect: outcome.unknownSideEffect ? (outcome.sideEffectDetail ?? "存在结果不确定的操作") : null,
1220
+ pendingPermissions: isSessionAwaitingPermission(session.id) ? 1 : 0,
1221
+ unsafeReplay: null,
1222
+ budgetExhausted: null,
1223
+ externalAuthRequired: block?.kind === "external_auth" ? { message: block.message, requiredAction: block.requiredAction } : null,
1224
+ inputRequired: block?.kind === "input" ? { message: block.message, requiredAction: block.requiredAction } : null,
1225
+ choiceRequired: block?.kind === "choice" ? { message: block.message, requiredAction: block.requiredAction } : null,
1226
+ unresolvedToolErrors: outcome.toolErrors,
1227
+ finalTextPresent: outcome.finalText.trim().length > 0,
1228
+ cancelled: outcome.aborted,
1229
+ };
1230
+ }
1231
+ /** 步骤变化 → 事件。粒度按「状态真的变了」算,重放时不会重复累计。 */
1232
+ async publishStepEvents(previous, next, sessionId) {
1233
+ const before = new Map(previous.map((step) => [step.id, step.status]));
1234
+ const completed = next.steps.filter((step) => step.status === "completed").length;
1235
+ for (const step of next.steps) {
1236
+ if (before.get(step.id) === step.status)
1237
+ continue;
1238
+ const type = step.status === "completed" ? "task.step.completed" : step.status === "in_progress" ? "task.step.started" : null;
1239
+ if (!type)
1240
+ continue;
1241
+ await this.publish({ type, data: { taskId: next.id, revision: next.revision, stepId: step.id, message: step.title, completedSteps: completed, totalSteps: next.steps.length } }, sessionId);
1242
+ }
1243
+ if (before.size !== next.steps.length) {
1244
+ await this.publish({
1245
+ type: "task.plan.updated",
1246
+ data: {
1247
+ taskId: next.id,
1248
+ revision: next.revision,
1249
+ goal: next.goal,
1250
+ steps: next.steps.map((step) => ({ id: step.id, title: step.title, status: step.status })),
1251
+ completedSteps: completed,
1252
+ totalSteps: next.steps.length,
1253
+ },
1254
+ }, sessionId);
1255
+ }
1256
+ }
1257
+ /** 把本轮的可观测事实投影进任务契约:步骤、证据、隐式验收条件、进度指纹。
1258
+ *
1259
+ * 顺序不能换:证据先于验收条件(条件要引用 evidence id),而指纹最后算
1260
+ * (它是对「投影后的完整状态」取摘要)。 */
1261
+ async syncTaskFromAttempt(task, outcome, sessionId) {
1262
+ const now = new Date().toISOString();
1263
+ // 1) 步骤(来自模型的 todo 拆解)
1264
+ const steps = outcome.todos
1265
+ ? projectTodos({ taskId: task.id, steps: task.steps, todos: outcome.todos, now })
1266
+ : task.steps;
1267
+ // 2) 证据(只收成功的写/执行/外部核对)
1268
+ let evidence = task.evidence;
1269
+ const dropped = [];
1270
+ for (const candidate of outcome.evidenceCandidates) {
1271
+ const appended = appendEvidence({ evidence, kind: candidate.kind, summary: candidate.summary, ...(candidate.ref ? { ref: candidate.ref } : {}), now });
1272
+ evidence = appended.evidence;
1273
+ dropped.push(...appended.dropped);
1274
+ }
1275
+ // 2b) 交付声明必须留下可追溯的证据(规格 §9 条件 3 的口子,§9 末段)。
1276
+ //
1277
+ // 【这里原来是「本轮只要有最终文本就记一条 model_observation」】那条规则把
1278
+ // 「文本非空」直接变成了「有证据」,而证据又是条件 3 的输入——两个弱事实串起来
1279
+ // 就凑出了一次交付。删掉它之后,纯解释 / 写作 / 问答类任务(没有工具可跑,
1280
+ // 最终内容本身是唯一可验证的东西)靠**交付声明**拿到那一条证据:声明里
1281
+ // `verification` 至少一条是 schema 强制的,也就是说模型必须先说清「怎么验证的」,
1282
+ // 才拿得到这个口子——从「说过话」变成「说清了验证方式」,这是那条放宽能成立的下限。
1283
+ //
1284
+ // 【ref 固定为 task_deliver】`appendEvidence` 的去重键是 (kind, ref),固定 ref
1285
+ // 让重复交付**更新同一条**而不是不断新增。这不是省空间:进度指纹里
1286
+ // `criteria.evidenceIds.length` 参与计算,每次交付都新增一条会让「反复交付」
1287
+ // 看起来像在推进,no-progress 保护就失效了。
1288
+ //
1289
+ // 【只在任务没有任何工具证据时补】有工具证据的任务不该靠模型的自述通过验证,
1290
+ // 那会让「有证据」重新退化成「说过话」。
1291
+ const hasToolEvidence = evidence.some((item) => item.kind !== "model_observation");
1292
+ let declarationEvidenceId;
1293
+ if (outcome.delivery && !hasToolEvidence) {
1294
+ const appended = appendEvidence({
1295
+ evidence,
1296
+ kind: "model_observation",
1297
+ summary: outcome.delivery.verification.join(";").slice(0, 160),
1298
+ ref: TASK_DELIVER_TOOL_ID,
1299
+ now,
1300
+ });
1301
+ evidence = appended.evidence;
1302
+ dropped.push(...appended.dropped);
1303
+ declarationEvidenceId = evidence.find((item) => item.kind === "model_observation" && item.ref === TASK_DELIVER_TOOL_ID)?.id;
1304
+ }
1305
+ // 3) 引用清理 + 验收条件。淘汰证据后必须同步清引用,否则条件会因为
1306
+ // 悬空引用永远不满足(Completion Gate 的条件 3 只认能对上号的证据)。
1307
+ const prunedSteps = pruneEvidenceRefs(steps, dropped);
1308
+ const prunedCriteria = pruneEvidenceRefs(task.acceptanceCriteria, dropped);
1309
+ // 验收条件的结论只有一个来源:模型在 `task_deliver` 里的显式声明。此前这里的
1310
+ // `projectImplicitCriterion` 会从「模型这轮有没有输出文本」推导出结论——那次
1311
+ // 推导就是「一句话停在冒号上也能交付」的成因(见 `plan.ts`)。
1312
+ const projection = outcome.delivery
1313
+ ? applyDelivery({
1314
+ criteria: prunedCriteria,
1315
+ delivery: outcome.delivery,
1316
+ evidence,
1317
+ ...(declarationEvidenceId ? { declarationEvidenceId } : {}),
1318
+ observedChanges: outcome.filesEdited,
1319
+ })
1320
+ : null;
1321
+ const acceptanceCriteria = projection?.criteria ?? prunedCriteria;
1322
+ // 4) 进度指纹(含本轮的文件改动)
1323
+ const projected = { ...task, steps: prunedSteps, evidence, acceptanceCriteria };
1324
+ const progress = advanceProgress(projected, { editedFiles: outcome.filesEdited, toolCalls: outcome.toolCalls });
1325
+ const updated = await this.casTask(task, {
1326
+ steps: prunedSteps,
1327
+ evidence,
1328
+ acceptanceCriteria,
1329
+ ...(projection ? { result: projection.result } : {}),
1330
+ lastFingerprint: progress.fingerprint,
1331
+ noProgressCount: progress.noProgressCount,
1332
+ // 时间预算的累计口径:这一轮实际跑了多久(见 `TaskRecord.activeMs`)。
1333
+ // 放在这里而不是 `settleTask`,是因为**每一轮**都要记账——只在结算时记,
1334
+ // 一个连跑 20 轮才停的任务会把整段时间全部漏掉,预算永远不触发。
1335
+ activeMs: (task.activeMs ?? 0) + outcome.durationMs,
1336
+ });
1337
+ await this.publishStepEvents(task.steps, updated, sessionId);
1338
+ return updated;
1339
+ }
1340
+ /** 任务进入终态(或阻塞):落库 + 发事件,返回这次 run 的 workflow 状态。
1341
+ *
1342
+ * blocked 返回 `"completed"` 而不是 `"failed"`:这次运行本身正常结束了,
1343
+ * 任务是在等外部动作。返回 failed 会让 workflow 层把它当成运行崩溃处理
1344
+ * (还会触发 recovery 路径),那是错的。 */
1345
+ async settleTask(task, verdict, session, stats) {
1346
+ const now = new Date().toISOString();
1347
+ const taskId = task.id;
1348
+ if (verdict.status === "delivered") {
1349
+ const result = task.result ?? fallbackResult({ task, filesEdited: stats.filesEdited, toolCalls: stats.toolCalls });
1350
+ const updated = await this.casTask(task, { status: "delivered", deliveredAt: now, result });
1351
+ await this.publish({
1352
+ type: "task.delivered",
1353
+ data: {
1354
+ taskId,
1355
+ revision: updated.revision,
1356
+ // `result` 是渲染好的文本(通知、复制、旧客户端都在用它);
1357
+ // `delivery` 是同一份信息的结构化形态,给交付卡按四问分别渲染。
1358
+ // 【为什么两份都给】把结构化数据塞进一个字符串再让客户端解析,是此前
1359
+ // 「剩余项」出现两次的原因(`lib/chat-projector.ts` 把整段文本当成
1360
+ // `outcome`,四问里剩下的位置自然全空)。发两份比让客户端做字符串解析稳。
1361
+ result: renderResult(result),
1362
+ delivery: {
1363
+ outcome: result.outcome,
1364
+ changes: [...result.changes],
1365
+ verification: [...result.verification],
1366
+ remaining: [...result.remaining],
1367
+ },
1368
+ // 交付卡上「验收 x/y · 证据 n 条」两个数字的**权威来源**。此前它们
1369
+ // 只能靠客户端从 task.started(那一刻的条件永远全是 pending)和证据
1370
+ // 数组(事件流里根本没有)拼出来,于是界面上恒显示「验收 0/1 · 证据
1371
+ // 0 条」,与实际相反——而这一行恰恰是规格 §18.4 给用户「不必相信这句
1372
+ // 完成」的核对依据,一个恒错的核对依据比没有更糟。
1373
+ criteria: updated.acceptanceCriteria.map((criterion) => ({
1374
+ id: criterion.id,
1375
+ description: criterion.description,
1376
+ required: criterion.required,
1377
+ status: criterion.status,
1378
+ })),
1379
+ evidenceCount: updated.evidence.length,
1380
+ evidenceIds: updated.evidence.map((item) => item.id),
1381
+ filesEdited: stats.filesEdited.length,
1382
+ toolCalls: stats.toolCalls,
1383
+ durationMs: stats.durationMs,
1384
+ },
1385
+ }, session.id);
1386
+ return "completed";
1387
+ }
1388
+ if (verdict.status === "failed") {
1389
+ const updated = await this.casTask(task, { status: "failed" });
1390
+ await this.publish({ type: "task.failed", data: { taskId, revision: updated.revision, reason: verdict.reason } }, session.id);
1391
+ return "failed";
1392
+ }
1393
+ if (verdict.status === "cancelled") {
1394
+ const updated = await this.casTask(task, { status: "cancelled" });
1395
+ await this.publish({ type: "task.cancelled", data: { taskId, revision: updated.revision, reason: verdict.reason } }, session.id);
1396
+ return "cancelled";
1397
+ }
1398
+ // blocked / waiting_*:阻塞种类决定状态,UI 据此给对应按钮(规格 §14.2)
1399
+ const status = lifecycleForBlocker(verdict.blocker.kind);
1400
+ const updated = await this.casTask(task, { status, blocker: verdict.blocker });
1401
+ await this.publish({ type: "task.blocked", data: { taskId, revision: updated.revision, blocker: verdict.blocker } }, session.id);
1402
+ return "completed";
1403
+ }
1404
+ /** 一个持久任务的执行循环(规格 §8.1 总流程)。
1405
+ *
1406
+ * 【为什么续跑在同一次调用内循环,而不是每轮新建一个 workflow run】
1407
+ * 1. lease 与串行队列:任务执行期间必须持有 session 的执行权。每轮重建 run
1408
+ * 意味着每轮都要重新 claim/lease,中间会出现「谁都没持有」的窗口——那
1409
+ * 正是两个 runner 并行改同一工作区的入口。
1410
+ * 2. 规格 §8.2.5 要求「继续使用 session 级串行队列和 lease」,循环是最直接
1411
+ * 的实现。轮次计数落在 `TaskRecord.attemptCount` 上,所以进程重启后恢复
1412
+ * 扫描仍能知道跑到第几轮。
1413
+ *
1414
+ * 出口只有两个:任务进入终态,或需要用户/外部动作。单次模型停止、step 上限、
1415
+ * 本轮没有工具调用,**都不是**出口(规格 §8.3 逐条列出)。 */
1416
+ async runTask(session, input, acceptedUserId) {
1417
+ const taskStore = this.deps.store.task;
1418
+ // 没有 TaskStore:退化为一次性运行。工具、权限、事件全部照常,只是没有
1419
+ // 「跨运行的目标」这层语义——这不是错误路径,而是宿主未启用该能力的降级。
1420
+ if (!taskStore)
1421
+ return (await this.runLoop(session, input, acceptedUserId)).state;
1422
+ let task = input.taskId ? await taskStore.getTask(input.taskId) : await taskStore.getActiveTask(session.id);
1423
+ if (!task)
1424
+ return (await this.runLoop(session, input, acceptedUserId)).state;
1425
+ // `waiting_*` / `blocked` 意味着「在等用户做一个具体动作」,唯一有权解除它的
1426
+ // 是 `resolveTaskForPrompt`——那条新消息本身就是用户动作。这里再挡一道,是因为
1427
+ // **排队中的补充消息可能在阻塞之后才被 drain 到**:用户写下它的时候还没看到
1428
+ // 阻塞原因,把它当成「已经看过并响应了」会静默清掉用户根本没读到的提示。
1429
+ // (那条消息的正文已经并进了 `constraints`,不会丢,用户真正回应时会被带上。)
1430
+ if (isWaitingStatus(task.status))
1431
+ return "completed";
1432
+ const maxAttempts = Math.min(MAX_ATTEMPT_CEILING, Math.max(1, Math.floor(this.deps.taskPolicy?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)));
1433
+ const maxDurationMs = normalizeMaxDurationMs(this.deps.taskPolicy?.maxDurationMs);
1434
+ const noProgressPolicy = this.deps.taskPolicy?.noProgress;
1435
+ // 从第二轮起,驱动这台机器的是任务自己(`continuation`),不再是「用户按过
1436
+ // 按钮」那件事。把 `resume` 摘掉,否则 driveText 会一路走放行话术,掩盖了
1437
+ // 真正发生的事(系统在自动续跑)。
1438
+ const { resume: _resume, ...strippedInput } = input;
1439
+ let attemptInput = input;
1440
+ let advisory;
1441
+ let continuation = false;
1442
+ // 整个任务共用一个循环防护实例(规格 §16 阶段 D「loop-guard 扩展为跨 Attempt」)
1443
+ const loopGuard = new LoopGuard();
1444
+ let stats = { filesEdited: [], toolCalls: 0, durationMs: 0 };
1445
+ while (true) {
1446
+ const attemptNumber = task.attemptCount + 1;
1447
+ // 时间预算先于轮数预算:两者的消息都指向「看一眼轨迹、确认目标是否要收窄」,
1448
+ // 但先报出的应该是**已经烧掉多少时间**——那是用户此刻最需要知道的事实,
1449
+ // 而轮数只在所有轮都很快时才成为瓶颈。
1450
+ const overTime = durationBudgetBlocker(task, maxDurationMs);
1451
+ if (overTime)
1452
+ return await this.settleTask(task, { status: "blocked", blocker: overTime }, session, stats);
1453
+ if (attemptNumber > maxAttempts) {
1454
+ return await this.settleTask(task, {
1455
+ status: "blocked",
1456
+ blocker: {
1457
+ kind: "budget",
1458
+ message: `已达单次任务的最大执行轮数(${maxAttempts} 轮)。`,
1459
+ requiredAction: "看一眼执行轨迹,确认目标是否需要收窄;确认后可以继续。",
1460
+ resumable: true,
1461
+ },
1462
+ }, session, stats);
1463
+ }
1464
+ task = await this.casTask(task, { status: "running", attemptCount: attemptNumber, blocker: undefined });
1465
+ // 续跑时明确告诉用户「系统在自己接着做」,而不是又开了一轮对话
1466
+ if (continuation) {
1467
+ await this.publish({ type: "task.recovery.started", data: { taskId: task.id, revision: task.revision, message: advisory ?? "继续推进任务", attempt: attemptNumber } }, session.id);
1468
+ }
1469
+ const outcome = await this.runLoop(session, attemptInput, acceptedUserId, {
1470
+ task,
1471
+ loopGuard,
1472
+ ...(advisory ? { advisory } : {}),
1473
+ ...(continuation ? { continuation: true } : {}),
1474
+ });
1475
+ stats = { filesEdited: outcome.filesEdited, toolCalls: outcome.toolCalls, durationMs: outcome.durationMs };
1476
+ task = await this.syncTaskFromAttempt(task, outcome, session.id);
1477
+ // 一次 Attempt 结束——**不是**任务完成(规格 §8.3)。UI 只能拿它画轨迹。
1478
+ await this.publish({
1479
+ type: "task.attempt.finished",
1480
+ data: {
1481
+ taskId: task.id,
1482
+ revision: task.revision,
1483
+ attempt: attemptNumber,
1484
+ outcome: outcome.aborted ? "aborted" : outcome.state === "failed" ? "error" : "completed",
1485
+ toolCalls: outcome.toolCalls,
1486
+ filesEdited: outcome.filesEdited.length,
1487
+ durationMs: outcome.durationMs,
1488
+ },
1489
+ }, session.id);
1490
+ const verdict = evaluateTaskCompletion(task, this.completionStateOf(outcome, session), noProgressPolicy);
1491
+ if (verdict.status !== "continue")
1492
+ return await this.settleTask(task, verdict, session, stats);
1493
+ // ---- 内部续跑:不创建用户消息,不新建 workflow run ----
1494
+ advisory = verdict.advisory;
1495
+ continuation = true;
1496
+ attemptInput = { ...strippedInput, continuation: { attempt: attemptNumber + 1, ...(advisory ? { advisory } : {}) } };
1497
+ // 上一轮是上游抖动的话,立刻重打一次没有意义——给它一个短退避
1498
+ if (outcome.state === "failed")
1499
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
1500
+ }
1501
+ }
761
1502
  /** Spawns a subagent child session (spec §6.4): depth-capped, permission
762
1503
  * stamped from parent session + subagent preset, runs a nested PI loop to
763
1504
  * completion, and returns the child's final assistant text as the parent
@@ -939,9 +1680,29 @@ export class SessionRunner {
939
1680
  .filter((part) => part.type === "text")
940
1681
  .map((part) => part.text)
941
1682
  .join("\n");
942
- const files = parts.filter((p) => p.type === "file" && p.url.startsWith("data:"));
943
- const attachments = files.map((p) => ({ name: p.filename, mediaType: p.mime, data: p.url, size: Buffer.from(p.url.slice(p.url.indexOf(",") + 1), "base64").length }));
944
- messages.push({ role: "user", content: [{ type: "text", text }, ...attachmentContent(attachments)], timestamp: Date.parse(info.time.created) || Date.now() });
1683
+ const fileParts = parts.filter((p) => p.type === "file");
1684
+ // 旧链路历史:data URL 内联部分保持原样重建,否则升级后老会话会「丢附件」。
1685
+ const legacy = fileParts.flatMap((p) => {
1686
+ const url = p.url;
1687
+ if (typeof url !== "string" || !url.startsWith("data:"))
1688
+ return [];
1689
+ return [{ name: p.filename, mediaType: p.mime, data: url, size: Buffer.from(url.slice(url.indexOf(",") + 1), "base64").length }];
1690
+ });
1691
+ // 新链路:按描述符重建,正文经 provider 读取(图片→视觉输入、小文本→内联、其余→清单)。
1692
+ // 因此重放历史**不会**把所有附件正文反复塞进后续每个 turn(规格 2 §11)。
1693
+ // 用 AttachmentContentRef 而不是 InputAttachmentRef:part 里没有 sha256,
1694
+ // 硬编一个假摘要会违反该类型的不变量。
1695
+ const refs = fileParts.flatMap((p) => p.attachmentId
1696
+ ? [{
1697
+ id: p.attachmentId,
1698
+ name: p.filename,
1699
+ mediaType: p.mime,
1700
+ ...(typeof p.size === "number" ? { size: p.size } : {}),
1701
+ kind: p.kind ?? "text",
1702
+ }]
1703
+ : []);
1704
+ const refParts = await attachmentRefContent(this.deps.attachments, refs, { sessionId });
1705
+ messages.push({ role: "user", content: [{ type: "text", text }, ...attachmentContent(legacy), ...refParts], timestamp: Date.parse(info.time.created) || Date.now() });
945
1706
  }
946
1707
  else {
947
1708
  const text = parts