@downcity/agent 1.1.320 → 1.1.323

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 (90) hide show
  1. package/bin/agent/Agent.d.ts.map +1 -1
  2. package/bin/agent/Agent.js +1 -0
  3. package/bin/agent/Agent.js.map +1 -1
  4. package/bin/agent/AgentSessions.d.ts +2 -0
  5. package/bin/agent/AgentSessions.d.ts.map +1 -1
  6. package/bin/agent/AgentSessions.js +9 -0
  7. package/bin/agent/AgentSessions.js.map +1 -1
  8. package/bin/executor/messages/SessionAttachmentMapper.d.ts +15 -7
  9. package/bin/executor/messages/SessionAttachmentMapper.d.ts.map +1 -1
  10. package/bin/executor/messages/SessionAttachmentMapper.js +22 -32
  11. package/bin/executor/messages/SessionAttachmentMapper.js.map +1 -1
  12. package/bin/index.d.ts +1 -0
  13. package/bin/index.d.ts.map +1 -1
  14. package/bin/index.js.map +1 -1
  15. package/bin/session/Session.d.ts +2 -0
  16. package/bin/session/Session.d.ts.map +1 -1
  17. package/bin/session/Session.js +9 -4
  18. package/bin/session/Session.js.map +1 -1
  19. package/bin/session/SessionLoop.js +2 -2
  20. package/bin/session/SessionLoop.js.map +1 -1
  21. package/bin/session/SessionMessages.d.ts +2 -1
  22. package/bin/session/SessionMessages.d.ts.map +1 -1
  23. package/bin/session/SessionMessages.js +14 -8
  24. package/bin/session/SessionMessages.js.map +1 -1
  25. package/bin/session/SessionState.d.ts +10 -9
  26. package/bin/session/SessionState.d.ts.map +1 -1
  27. package/bin/session/SessionState.js +108 -54
  28. package/bin/session/SessionState.js.map +1 -1
  29. package/bin/session/SessionTitle.d.ts +4 -0
  30. package/bin/session/SessionTitle.d.ts.map +1 -1
  31. package/bin/session/SessionTitle.js +5 -1
  32. package/bin/session/SessionTitle.js.map +1 -1
  33. package/bin/session/messages/SessionAssistantMessageWriter.d.ts +1 -1
  34. package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
  35. package/bin/session/messages/SessionAssistantMessageWriter.js +4 -4
  36. package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
  37. package/bin/session/runtime/SessionTitleTask.d.ts +35 -0
  38. package/bin/session/runtime/SessionTitleTask.d.ts.map +1 -0
  39. package/bin/session/runtime/SessionTitleTask.js +72 -0
  40. package/bin/session/runtime/SessionTitleTask.js.map +1 -0
  41. package/bin/types/sdk/AgentSessionPrompt.d.ts +22 -0
  42. package/bin/types/sdk/AgentSessionPrompt.d.ts.map +1 -1
  43. package/bin/types/sdk/AgentSessionPrompt.js.map +1 -1
  44. package/bin/types/session/SessionMessages.d.ts +3 -0
  45. package/bin/types/session/SessionMessages.d.ts.map +1 -1
  46. package/bin/types/session/SessionOptions.d.ts +2 -0
  47. package/bin/types/session/SessionOptions.d.ts.map +1 -1
  48. package/bin/types/store/SessionAttachmentStore.d.ts +29 -0
  49. package/bin/types/store/SessionAttachmentStore.d.ts.map +1 -0
  50. package/bin/types/store/SessionAttachmentStore.js +15 -0
  51. package/bin/types/store/SessionAttachmentStore.js.map +1 -0
  52. package/bin/types/store/SessionStore.d.ts +3 -0
  53. package/bin/types/store/SessionStore.d.ts.map +1 -1
  54. package/bin/workspace/store/LocalSessionAttachmentStore.d.ts +25 -0
  55. package/bin/workspace/store/LocalSessionAttachmentStore.d.ts.map +1 -0
  56. package/bin/workspace/store/LocalSessionAttachmentStore.js +72 -0
  57. package/bin/workspace/store/LocalSessionAttachmentStore.js.map +1 -0
  58. package/bin/workspace/store/LocalSessionStore.d.ts +3 -0
  59. package/bin/workspace/store/LocalSessionStore.d.ts.map +1 -1
  60. package/bin/workspace/store/LocalSessionStore.js +8 -1
  61. package/bin/workspace/store/LocalSessionStore.js.map +1 -1
  62. package/bin/workspace/store/LocalStorePaths.d.ts +6 -0
  63. package/bin/workspace/store/LocalStorePaths.d.ts.map +1 -1
  64. package/bin/workspace/store/LocalStorePaths.js +8 -0
  65. package/bin/workspace/store/LocalStorePaths.js.map +1 -1
  66. package/package.json +1 -1
  67. package/scripts/session-attachments.test.mjs +101 -0
  68. package/scripts/session-list-title.test.mjs +6 -0
  69. package/scripts/session-title-event.test.mjs +67 -3
  70. package/scripts/session-turn-failure.test.mjs +34 -1
  71. package/src/agent/Agent.ts +1 -0
  72. package/src/agent/AgentSessions.ts +10 -0
  73. package/src/executor/messages/SessionAttachmentMapper.ts +24 -37
  74. package/src/index.ts +1 -0
  75. package/src/session/Session.ts +10 -4
  76. package/src/session/SessionLoop.ts +2 -2
  77. package/src/session/SessionMessages.ts +17 -7
  78. package/src/session/SessionState.ts +118 -61
  79. package/src/session/SessionTitle.ts +13 -1
  80. package/src/session/messages/SessionAssistantMessageWriter.ts +7 -3
  81. package/src/session/runtime/SessionTitleTask.ts +86 -0
  82. package/src/types/sdk/AgentSessionPrompt.ts +22 -0
  83. package/src/types/session/SessionMessages.ts +3 -0
  84. package/src/types/session/SessionOptions.ts +3 -0
  85. package/src/types/store/SessionAttachmentStore.ts +29 -0
  86. package/src/types/store/SessionStore.ts +4 -0
  87. package/src/workspace/store/LocalSessionAttachmentStore.ts +96 -0
  88. package/src/workspace/store/LocalSessionStore.ts +13 -0
  89. package/src/workspace/store/LocalStorePaths.ts +16 -0
  90. package/tsconfig.tsbuildinfo +1 -1
@@ -3,9 +3,17 @@
3
3
  *
4
4
  * 关键点(中文)
5
5
  * - 兼容 Telegram / Feishu / TUI 等统一的 `<file>` 协议入口。
6
- * - 仅在本轮执行的内存消息上追加 file parts,不修改持久化历史。
7
- * - 当前只为图片与 PDF 注入 file part,保持多模态模型可直接消费。
6
+ * - Data URL 附件先由 Session Attachment Store 落盘,Message 只保存文件路径。
7
+ * - 模型执行阶段再读取本地文件,并转换为模型可消费的数据格式。
8
8
  * - 历史中的相对路径与旧版 `file://` 会在喂给模型前临时 hydrate。
9
+ *
10
+ * 输入到输出(中文):
11
+ * `file.url = data:<media-type>;base64,...`
12
+ * → Session Attachment Store 解码并写入附件文件
13
+ * → `file.url = .downcity/.../attachments/att_<id>.<ext>`
14
+ * → Message 持久化路径引用
15
+ * → 模型执行前读取文件并恢复为 Data URL
16
+ * → `convertToModelMessages()` 生成最终 ModelMessage。
9
17
  */
10
18
 
11
19
  import fs from "fs-extra";
@@ -17,6 +25,7 @@ import {
17
25
  type FileUIPart,
18
26
  } from "ai";
19
27
  import type { SessionUserMessagePart } from "@/types/sdk/AgentSessionPrompt.js";
28
+ import type { SessionAttachmentStore } from "@/types/store/SessionAttachmentStore.js";
20
29
  import type {
21
30
  SessionRecordV1,
22
31
  SessionMessageRecordV1,
@@ -106,42 +115,16 @@ async function hydrateFileUrlPart(
106
115
  }
107
116
  }
108
117
 
109
- async function hydrateFileUrlPartStrict(
110
- part: FileUIPart,
111
- project_root?: string,
112
- ): Promise<FileUIPart> {
113
- const url = String(part.url || "").trim();
114
- const file_path = resolveHydratableFilePath(project_root, url);
115
- if (!file_path) return part;
116
-
117
- try {
118
- const buffer = await fs.readFile(file_path);
119
- const media_type =
120
- String(part.mediaType || "").trim() ||
121
- guessAttachmentMediaTypeFromPath(file_path) ||
122
- "application/octet-stream";
123
- return {
124
- ...part,
125
- mediaType: media_type,
126
- url: buildDataUrl(media_type, buffer),
127
- };
128
- } catch (error) {
129
- const message = error instanceof Error ? error.message : String(error);
130
- throw new Error(`读取本地附件失败:${file_path}。${message}`);
131
- }
132
- }
133
-
134
118
  /**
135
- * 在用户 prompt 入库前,将本地图片 file part 转成 data URL。
119
+ * 在用户 prompt 入库前,将 Data URL file part 保存为 Session 附件路径。
136
120
  *
137
121
  * 关键点(中文)
138
- * - 只处理调用侧直接传入的图片 file part。
139
- * - 已经是 data URL 或远程 URL 的附件保持原样。
140
- * - 本地文件读取失败时直接报错,避免模型请求拿到不可访问的本地路径。
122
+ * - 只有 Data URL 会在此处落盘;远程 URL 和本地路径保持引用不变。
123
+ * - 附件成功落盘后才把 URL 替换为相对 Workspace 根目录的路径。
141
124
  */
142
- export async function hydrate_user_prompt_file_parts(
125
+ export async function persist_user_prompt_file_parts(
143
126
  parts: SessionUserMessagePart[],
144
- project_root?: string,
127
+ attachment_store: SessionAttachmentStore,
145
128
  ): Promise<SessionUserMessagePart[]> {
146
129
  if (!Array.isArray(parts) || parts.length === 0) return [];
147
130
 
@@ -153,14 +136,18 @@ export async function hydrate_user_prompt_file_parts(
153
136
  }
154
137
 
155
138
  const file_part = part as FileUIPart;
156
- const media_type = String(file_part.mediaType || "").trim();
157
- if (!media_type.startsWith("image/")) {
139
+ const url = String(file_part.url || "").trim();
140
+ if (!url.startsWith("data:")) {
158
141
  out.push(part);
159
142
  continue;
160
143
  }
161
144
 
162
- const next_part = await hydrateFileUrlPartStrict(file_part, project_root);
163
- out.push(next_part as SessionUserMessagePart);
145
+ const stored_path = await attachment_store.persist_data_url({
146
+ data_url: url,
147
+ media_type: String(file_part.mediaType || "").trim(),
148
+ ...(file_part.filename ? { filename: file_part.filename } : {}),
149
+ });
150
+ out.push({ ...file_part, url: stored_path } as SessionUserMessagePart);
164
151
  }
165
152
 
166
153
  return out;
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export type {
18
18
  SessionMessageStore,
19
19
  SessionStore,
20
20
  } from "./types/store/SessionStore.js";
21
+ export type { SessionAttachmentStore } from "./types/store/SessionAttachmentStore.js";
21
22
  export { create_session_message_store } from "./workspace/store/SessionMessageStoreFactory.js";
22
23
  export type {
23
24
  FileSystem,
@@ -150,6 +150,7 @@ export class Session implements AgentSession {
150
150
  this.session_messages = new SessionMessages({
151
151
  session_id: this.id,
152
152
  store: this.store.messages,
153
+ attachment_store: this.store.attachments,
153
154
  publish: (mutation) => {
154
155
  this.events.publish(mutation);
155
156
  },
@@ -476,8 +477,8 @@ export class Session implements AgentSession {
476
477
  text: String(input.text || "").trim(),
477
478
  });
478
479
  if (!appended) return;
479
- await this.state.ensure_title_from_history({ generate: true });
480
- await this.state.touch_metadata();
480
+ this.state.touch_metadata_in_background();
481
+ this.state.schedule_title_generation();
481
482
  }
482
483
 
483
484
  /**
@@ -636,8 +637,8 @@ export class Session implements AgentSession {
636
637
  message_params,
637
638
  );
638
639
  if (!appended) return;
639
- await this.state.ensure_title_from_history({ generate: true });
640
- await this.state.touch_metadata();
640
+ this.state.touch_metadata_in_background();
641
+ this.state.schedule_title_generation();
641
642
  },
642
643
  append_assistant_message: async (message_params) => {
643
644
  const appended = await this.session_messages.append_external_assistant_message({
@@ -655,6 +656,11 @@ export class Session implements AgentSession {
655
656
  return this.runtime_port;
656
657
  }
657
658
 
659
+ /** 取消并释放当前 Session 的标题后台任务。 */
660
+ dispose_title_generation(): void {
661
+ this.state.dispose_title_generation();
662
+ }
663
+
658
664
  /**
659
665
  * 在执行前确保 session 已完成初始化与宿主装配。
660
666
  */
@@ -593,8 +593,8 @@ export class SessionLoop {
593
593
  turn_id,
594
594
  input_type,
595
595
  });
596
- await this.state.ensure_title_from_history({ generate: true });
597
- await this.state.touch_metadata();
596
+ this.state.touch_metadata_in_background();
597
+ this.state.schedule_title_generation();
598
598
  return message;
599
599
  }
600
600
 
@@ -50,7 +50,7 @@ import {
50
50
  from_ui_user_parts,
51
51
  to_executor_ui_message,
52
52
  } from "@/session/messages/SessionMessageCodec.js";
53
- import { hydrate_user_prompt_file_parts } from "@executor/messages/SessionAttachmentMapper.js";
53
+ import { persist_user_prompt_file_parts } from "@executor/messages/SessionAttachmentMapper.js";
54
54
  import type {
55
55
  AppendCompletedAssistantMessageInput,
56
56
  AppendExternalSessionAssistantMessageInput,
@@ -63,6 +63,7 @@ import type {
63
63
  SessionMessagesOptions,
64
64
  } from "@/types/session/SessionMessages.js";
65
65
  import type { SessionMessageStore } from "@/types/store/SessionStore.js";
66
+ import type { SessionAttachmentStore } from "@/types/store/SessionAttachmentStore.js";
66
67
 
67
68
  export { SessionAssistantMessageWriter } from "@/session/messages/SessionAssistantMessageWriter.js";
68
69
 
@@ -70,6 +71,7 @@ export { SessionAssistantMessageWriter } from "@/session/messages/SessionAssista
70
71
  export class SessionMessages {
71
72
  readonly session_id: string;
72
73
  private readonly store: SessionMessageStore;
74
+ private readonly attachment_store: SessionAttachmentStore;
73
75
  private readonly publish: SessionMessagesOptions["publish"];
74
76
  private readonly messages_by_id = new Map<string, SessionMessage>();
75
77
  /** 按 Assistant Message 隔离的完整写事务链。 */
@@ -81,6 +83,7 @@ export class SessionMessages {
81
83
  constructor(options: SessionMessagesOptions) {
82
84
  this.session_id = String(options.session_id || "").trim();
83
85
  this.store = options.store;
86
+ this.attachment_store = options.attachment_store;
84
87
  this.publish = options.publish;
85
88
  if (!this.session_id) throw new Error("SessionMessages requires session_id");
86
89
  this.interaction_writer = new SessionMessageInteractionWriter({
@@ -282,9 +285,9 @@ export class SessionMessages {
282
285
  const query = input.prompt.query;
283
286
  const ui_parts = typeof query === "string"
284
287
  ? [{ type: "text" as const, text: query.trim() }]
285
- : await hydrate_user_prompt_file_parts(
288
+ : await persist_user_prompt_file_parts(
286
289
  Array.isArray(query) ? query : [],
287
- input.project_root,
290
+ this.attachment_store,
288
291
  );
289
292
  const canonical = await this.append_user_message({
290
293
  turn_id: input.turn_id,
@@ -721,9 +724,10 @@ export class SessionMessages {
721
724
  async complete_assistant_message(
722
725
  message_id: string,
723
726
  status: "completed" | "stopped" | "failed",
727
+ error?: string,
724
728
  ): Promise<void> {
725
729
  await this.enqueue_assistant_write(message_id, async () => {
726
- await this.complete_assistant_message_serialized(message_id, status);
730
+ await this.complete_assistant_message_serialized(message_id, status, error);
727
731
  });
728
732
  }
729
733
 
@@ -731,6 +735,7 @@ export class SessionMessages {
731
735
  private async complete_assistant_message_serialized(
732
736
  message_id: string,
733
737
  status: "completed" | "stopped" | "failed",
738
+ error?: string,
734
739
  ): Promise<void> {
735
740
  const current = require_message([...this.messages_by_id.values()], message_id, "assistant");
736
741
  require_streaming_assistant(current);
@@ -767,13 +772,18 @@ export class SessionMessages {
767
772
  }
768
773
  if (
769
774
  part.type === "tool" &&
770
- part.state === "waiting-user" &&
771
- interrupted_tool_ids.has(part.tool_call_id)
775
+ part.state !== "completed" &&
776
+ part.state !== "failed"
772
777
  ) {
773
778
  return {
774
779
  ...part,
775
780
  state: "failed" as const,
776
- error: "Interaction cancelled",
781
+ error:
782
+ status === "stopped" &&
783
+ part.state === "waiting-user" &&
784
+ interrupted_tool_ids.has(part.tool_call_id)
785
+ ? "Interaction cancelled"
786
+ : error || "Tool did not complete before Assistant Message closed",
777
787
  };
778
788
  }
779
789
  return part;
@@ -21,8 +21,10 @@ import type { SessionLocalState } from "@/types/session/SessionLocalState.js";
21
21
  import { generate_id } from "@/utils/Id.js";
22
22
  import type { Logger } from "@/utils/logger/Logger.js";
23
23
  import { SessionMessages } from "@/session/SessionMessages.js";
24
+ import { SessionTitleTask } from "@/session/runtime/SessionTitleTask.js";
24
25
  import { to_executor_history } from "@/session/messages/SessionMessageCodec.js";
25
26
  import type { SessionMessage } from "@/types/session/SessionMessage.js";
27
+ import { is_session_message_record } from "@/executor/types/SessionRecords.js";
26
28
  import type { SessionStateOptions } from "@/types/session/SessionState.js";
27
29
  import type { SessionStore } from "@/types/store/SessionStore.js";
28
30
  import type { SessionApprovalMode } from "@/types/session/SessionInteraction.js";
@@ -48,6 +50,8 @@ export class SessionState {
48
50
  private readonly ensure_configured_hook?: SessionStateOptions["ensure_configured_hook"];
49
51
  private readonly get_model: SessionStateOptions["get_model"];
50
52
  private readonly publish_event: SessionStateOptions["publish_event"];
53
+ private readonly title_task: SessionTitleTask;
54
+ private metadata_mutation_chain: Promise<void> = Promise.resolve();
51
55
 
52
56
  constructor(options: SessionStateOptions) {
53
57
  this.agent_id = options.agent_id;
@@ -59,6 +63,10 @@ export class SessionState {
59
63
  this.ensure_configured_hook = options.ensure_configured_hook;
60
64
  this.get_model = options.get_model;
61
65
  this.publish_event = options.publish_event;
66
+ this.title_task = new SessionTitleTask({
67
+ session_id: this.session_id,
68
+ logger: this.logger,
69
+ });
62
70
  }
63
71
 
64
72
  /**
@@ -168,12 +176,14 @@ export class SessionState {
168
176
  model_context_window: read_agent_model_context_window(model),
169
177
  };
170
178
  if (changed) {
171
- const metadata = await this.store.read_metadata();
172
- await this.store.write_metadata({
173
- ...metadata,
174
- agent_id: this.agent_id,
175
- updated_at: Date.now(),
176
- ...(next_model_label ? { model_label: next_model_label } : {}),
179
+ await this.run_metadata_mutation(async () => {
180
+ const metadata = await this.store.read_metadata();
181
+ await this.store.write_metadata({
182
+ ...metadata,
183
+ agent_id: this.agent_id,
184
+ updated_at: Date.now(),
185
+ ...(next_model_label ? { model_label: next_model_label } : {}),
186
+ });
177
187
  });
178
188
  }
179
189
  this.state.session_config = next_config;
@@ -183,12 +193,14 @@ export class SessionState {
183
193
  /** 接受并持久化当前 Session 的 Shell 审批模式。 */
184
194
  async set_approval_mode(mode: SessionApprovalMode): Promise<boolean> {
185
195
  if (mode === this.state.configured_approval_mode) return false;
186
- const metadata = await this.store.read_metadata();
187
- await this.store.write_metadata({
188
- ...metadata,
189
- agent_id: this.agent_id,
190
- updated_at: Date.now(),
191
- approval_mode: mode,
196
+ await this.run_metadata_mutation(async () => {
197
+ const metadata = await this.store.read_metadata();
198
+ await this.store.write_metadata({
199
+ ...metadata,
200
+ agent_id: this.agent_id,
201
+ updated_at: Date.now(),
202
+ approval_mode: mode,
203
+ });
192
204
  });
193
205
  this.state.configured_approval_mode = mode;
194
206
  return true;
@@ -209,62 +221,107 @@ export class SessionState {
209
221
  const preview_text = resolve_message_preview(
210
222
  stats.latest_message || undefined,
211
223
  ).slice(0, 180);
212
- const metadata = await this.store.read_metadata();
213
- await this.store.write_metadata({
214
- ...metadata,
215
- agent_id: this.agent_id,
216
- updated_at: Date.now(),
217
- ...(this.state.session_config.model_label
218
- ? { model_label: this.state.session_config.model_label }
219
- : {}),
220
- message_count: stats.message_count,
221
- historyBytes: stats.history_bytes,
222
- ...(preview_text ? { preview_text: preview_text } : {}),
224
+ await this.run_metadata_mutation(async () => {
225
+ const metadata = await this.store.read_metadata();
226
+ await this.store.write_metadata({
227
+ ...metadata,
228
+ agent_id: this.agent_id,
229
+ updated_at: Date.now(),
230
+ ...(this.state.session_config.model_label
231
+ ? { model_label: this.state.session_config.model_label }
232
+ : {}),
233
+ message_count: stats.message_count,
234
+ historyBytes: stats.history_bytes,
235
+ ...(preview_text ? { preview_text: preview_text } : {}),
236
+ });
223
237
  });
224
238
  }
225
239
 
226
- /**
227
- * 确保当前 session 已持久化 title。
228
- */
229
- async ensure_title_from_history(input?: {
230
- /**
231
- * 是否允许调用模型生成标题。
232
- */
233
- generate?: boolean;
234
- }): Promise<void> {
235
- const messages = to_executor_history(
236
- this.session_id,
237
- await this.messages.context_snapshot(),
238
- );
239
- const before_metadata = await this.store.read_metadata();
240
- const before_title = String(before_metadata.title || "").trim();
241
- const next_metadata = await ensure_session_title({
242
- session_id: this.session_id,
243
- store: this.store,
244
- messages,
245
- ...(input?.generate
246
- ? {
247
- model: this.get_model(),
248
- }
249
- : {}),
250
- ...(this.state.session_config.model_label
251
- ? { model_label: this.state.session_config.model_label }
252
- : {}),
253
- logger: this.logger,
254
- generate: input?.generate === true,
240
+ /** 在后台刷新 Session metadata,不阻塞当前 Turn。 */
241
+ touch_metadata_in_background(): void {
242
+ void this.touch_metadata().catch(async (error) => {
243
+ try {
244
+ await this.logger.log("warn", "[agent] session_metadata.background_update_failed", {
245
+ session_id: this.session_id,
246
+ error: error instanceof Error ? error.message : String(error),
247
+ });
248
+ } catch {
249
+ // metadata 诊断日志失败不能影响当前 Turn。
250
+ }
255
251
  });
256
- const next_title = String(next_metadata.title || "").trim();
257
- if (!next_title || next_title === before_title) return;
258
- this.publish_event({
259
- mutation_id: generate_id(),
260
- variant: "session",
261
- type: "title",
262
- session_id: this.session_id,
263
- created_at: Date.now(),
264
- title: next_title,
252
+ }
253
+
254
+ /** 异步调度首条用户消息的 Session 标题生成。 */
255
+ schedule_title_generation(): void {
256
+ this.title_task.schedule(async (signal) => {
257
+ const before_metadata = await this.store.read_metadata();
258
+ if (String(before_metadata.title || "").trim()) return;
259
+ const records = to_executor_history(
260
+ this.session_id,
261
+ await this.messages.context_snapshot(),
262
+ );
263
+ const first_user_message = records.find(
264
+ (record) => is_session_message_record(record) && record.role === "user",
265
+ );
266
+ if (!first_user_message) return;
267
+ const first_user_message_id = first_user_message.id;
268
+ const before_title = String(before_metadata.title || "").trim();
269
+ const next_metadata = await ensure_session_title({
270
+ session_id: this.session_id,
271
+ store: this.store,
272
+ messages: records,
273
+ model: this.get_model(),
274
+ model_label: this.state.session_config.model_label,
275
+ logger: this.logger,
276
+ generate: true,
277
+ signal,
278
+ commit_title: async (title) => await this.run_metadata_mutation(async () => {
279
+ const latest_metadata = await this.store.read_metadata();
280
+ if (signal.aborted) return latest_metadata;
281
+ if (String(latest_metadata.title || "").trim()) return latest_metadata;
282
+ const latest_records = to_executor_history(
283
+ this.session_id,
284
+ await this.messages.context_snapshot(),
285
+ );
286
+ const source_exists = latest_records.some(
287
+ (record) =>
288
+ is_session_message_record(record) &&
289
+ record.id === first_user_message_id,
290
+ );
291
+ if (!source_exists || signal.aborted) return latest_metadata;
292
+ const next_metadata = { ...latest_metadata, title };
293
+ await this.store.write_metadata(next_metadata);
294
+ return next_metadata;
295
+ }),
296
+ });
297
+ const next_title = String(next_metadata.title || "").trim();
298
+ if (!next_title || next_title === before_title || signal.aborted) return;
299
+ this.publish_event({
300
+ mutation_id: generate_id(),
301
+ variant: "session",
302
+ type: "title",
303
+ session_id: this.session_id,
304
+ created_at: Date.now(),
305
+ title: next_title,
306
+ });
265
307
  });
266
308
  }
267
309
 
310
+ /** 取消并释放当前 Session 的标题后台任务。 */
311
+ dispose_title_generation(): void {
312
+ this.title_task.dispose();
313
+ }
314
+
315
+ /** 串行提交 Session metadata,避免后台标题覆盖其他字段。 */
316
+ private async run_metadata_mutation<T>(mutation: () => Promise<T>): Promise<T> {
317
+ const task = this.metadata_mutation_chain.then(mutation, mutation);
318
+ this.metadata_mutation_chain = task.then(
319
+ () => undefined,
320
+ () => undefined,
321
+ );
322
+ return await task;
323
+ }
324
+
268
325
  }
269
326
 
270
327
  function resolve_message_preview(message: SessionMessage | undefined): string {
@@ -53,6 +53,12 @@ export interface EnsureSessionTitleParams {
53
53
  * 是否允许调用模型生成标题。
54
54
  */
55
55
  generate?: boolean;
56
+
57
+ /** 可选的标题请求取消信号。 */
58
+ signal?: AbortSignal;
59
+
60
+ /** 可选的标题提交入口,用于与其他 metadata mutation 串行化。 */
61
+ commit_title?: (title: string) => Promise<SessionHistoryMetaV1>;
56
62
  }
57
63
 
58
64
  function truncateTitle(input: string, maxChars: number): string {
@@ -192,6 +198,9 @@ async function generateSessionTitle(input: {
192
198
  * 当前 session 运行日志器。
193
199
  */
194
200
  logger?: Logger;
201
+
202
+ /** 标题请求取消信号。 */
203
+ signal?: AbortSignal;
195
204
  }): Promise<string | undefined> {
196
205
  let observedStreamError: unknown;
197
206
  try {
@@ -208,6 +217,7 @@ async function generateSessionTitle(input: {
208
217
  onError: ({ error }) => {
209
218
  observedStreamError = error;
210
219
  },
220
+ abortSignal: input.signal,
211
221
  });
212
222
  const text = await result.text;
213
223
  const generatedTitle = normalizeGeneratedTitle(text);
@@ -277,11 +287,13 @@ export async function ensure_session_title(
277
287
  model_label: input.model_label,
278
288
  firstUserText,
279
289
  logger: input.logger,
290
+ signal: input.signal,
280
291
  });
281
292
  if (!generatedTitle) return current;
282
293
 
294
+ if (input.commit_title) return await input.commit_title(generatedTitle);
283
295
  const generatedMeta: SessionHistoryMetaV1 = {
284
- ...current,
296
+ ...(await input.store.read_metadata()),
285
297
  title: generatedTitle,
286
298
  };
287
299
  await input.store.write_metadata(generatedMeta);
@@ -559,9 +559,12 @@ export class SessionAssistantMessageWriter {
559
559
  }
560
560
 
561
561
  /** 以失败状态关闭当前 Assistant Message。 */
562
- async fail(_error: unknown): Promise<void> {
562
+ async fail(error: unknown): Promise<void> {
563
563
  await this.enqueue_write(async () => {
564
- await this.close_serialized("failed");
564
+ await this.close_serialized(
565
+ "failed",
566
+ error instanceof Error ? error.message : String(error || ""),
567
+ );
565
568
  });
566
569
  }
567
570
 
@@ -786,13 +789,14 @@ export class SessionAssistantMessageWriter {
786
789
  /** 在队列内关闭当前 Assistant Message。 */
787
790
  private async close_serialized(
788
791
  status: "completed" | "stopped" | "failed",
792
+ error?: string,
789
793
  ): Promise<void> {
790
794
  if (this.closed) return;
791
795
  this.tool_part_gate.close(
792
796
  `Assistant Message writer closed with status ${status}`,
793
797
  );
794
798
  this.reset_step_state();
795
- await this.recorder.complete_assistant_message(this.message_id, status);
799
+ await this.recorder.complete_assistant_message(this.message_id, status, error);
796
800
  this.closed = true;
797
801
  }
798
802
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Session 标题后台任务协调器。
3
+ *
4
+ * 关键点(中文)
5
+ * - 标题生成属于 metadata 增强任务,不得阻塞 Turn 主链路。
6
+ * - 同一个 Session 同时只允许一个标题任务运行。
7
+ * - 任务失败只记录日志,不能反向影响 Prompt 或 Executor。
8
+ */
9
+
10
+ import type { Logger } from "@/utils/logger/Logger.js";
11
+
12
+ /** 标题后台任务执行函数。 */
13
+ export type SessionTitleTaskRunner = (signal: AbortSignal) => Promise<void>;
14
+
15
+ /** Session 标题后台任务协调器配置。 */
16
+ export interface SessionTitleTaskOptions {
17
+ /** 当前 Session 的稳定标识。 */
18
+ session_id: string;
19
+
20
+ /** 用于记录后台任务失败的统一日志器。 */
21
+ logger: Logger;
22
+ }
23
+
24
+ /** Session 标题后台任务协调器。 */
25
+ export class SessionTitleTask {
26
+ private readonly session_id: string;
27
+ private readonly logger: Logger;
28
+ private active_task: Promise<void> | null = null;
29
+ private abort_controller: AbortController | null = null;
30
+ private pending_runner: SessionTitleTaskRunner | null = null;
31
+ private disposed = false;
32
+
33
+ constructor(options: SessionTitleTaskOptions) {
34
+ this.session_id = String(options.session_id || "").trim();
35
+ this.logger = options.logger;
36
+ if (!this.session_id) {
37
+ throw new Error("SessionTitleTask requires a non-empty session_id");
38
+ }
39
+ }
40
+
41
+ /** 调度标题任务;已有任务或已释放时直接忽略。 */
42
+ schedule(runner: SessionTitleTaskRunner): void {
43
+ if (this.disposed) return;
44
+ if (this.active_task) {
45
+ this.pending_runner = runner;
46
+ return;
47
+ }
48
+ const abort_controller = new AbortController();
49
+ this.abort_controller = abort_controller;
50
+ const task = Promise.resolve()
51
+ .then(async () => await runner(abort_controller.signal))
52
+ .catch(async (error) => {
53
+ if (abort_controller.signal.aborted) return;
54
+ try {
55
+ await this.logger.log("warn", "[agent] session_title.task_failed", {
56
+ session_id: this.session_id,
57
+ error: error instanceof Error ? error.message : String(error),
58
+ });
59
+ } catch {
60
+ // 标题诊断日志失败不能影响 Session 主流程。
61
+ }
62
+ })
63
+ .finally(() => {
64
+ if (this.active_task === task) {
65
+ this.active_task = null;
66
+ this.abort_controller = null;
67
+ const pending_runner = this.pending_runner;
68
+ this.pending_runner = null;
69
+ if (pending_runner && !this.disposed) this.schedule(pending_runner);
70
+ }
71
+ });
72
+ this.active_task = task;
73
+ }
74
+
75
+ /** 取消当前标题任务并阻止后续结果提交。 */
76
+ cancel(): void {
77
+ this.abort_controller?.abort();
78
+ }
79
+
80
+ /** 释放协调器;释放后不再接受新的标题任务。 */
81
+ dispose(): void {
82
+ this.disposed = true;
83
+ this.pending_runner = null;
84
+ this.cancel();
85
+ }
86
+ }
@@ -19,6 +19,28 @@ export type SessionUserMessagePart = UIMessagePart<UIDataTypes, UITools>;
19
19
 
20
20
  /**
21
21
  * Session prompt 输入。
22
+ *
23
+ * 输入示例(中文):
24
+ * ```ts
25
+ * session.prompt({
26
+ * query: [
27
+ * { type: "text", text: "请分析这个附件" },
28
+ * {
29
+ * type: "file",
30
+ * mediaType: "image/png",
31
+ * url: "data:image/png;base64,...",
32
+ * filename: "image.png",
33
+ * },
34
+ * ],
35
+ * });
36
+ * ```
37
+ *
38
+ * 生命周期说明(中文):
39
+ * - `string` 会直接成为新的文本 User Message。
40
+ * - `file` part 如果携带 Data URL,会在 Message 入库前解码到 Session 的 attachments 目录。
41
+ * - 持久化 Message 里的 `url` 变成相对 Workspace 根目录的文件路径,而不是 Base64 内容。
42
+ * - 模型执行前,Executor 再按该路径读取文件,并转换成 AI SDK / Provider 可消费的 ModelMessage。
43
+ * - 远程 Session 中,Data URL 在服务端落盘;调用方本地路径必须对服务端可访问。
22
44
  */
23
45
  export interface AgentSessionPromptInput {
24
46
  /**