@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.
- package/bin/agent/Agent.d.ts.map +1 -1
- package/bin/agent/Agent.js +1 -0
- package/bin/agent/Agent.js.map +1 -1
- package/bin/agent/AgentSessions.d.ts +2 -0
- package/bin/agent/AgentSessions.d.ts.map +1 -1
- package/bin/agent/AgentSessions.js +9 -0
- package/bin/agent/AgentSessions.js.map +1 -1
- package/bin/executor/messages/SessionAttachmentMapper.d.ts +15 -7
- package/bin/executor/messages/SessionAttachmentMapper.d.ts.map +1 -1
- package/bin/executor/messages/SessionAttachmentMapper.js +22 -32
- package/bin/executor/messages/SessionAttachmentMapper.js.map +1 -1
- package/bin/index.d.ts +1 -0
- package/bin/index.d.ts.map +1 -1
- package/bin/index.js.map +1 -1
- package/bin/session/Session.d.ts +2 -0
- package/bin/session/Session.d.ts.map +1 -1
- package/bin/session/Session.js +9 -4
- package/bin/session/Session.js.map +1 -1
- package/bin/session/SessionLoop.js +2 -2
- package/bin/session/SessionLoop.js.map +1 -1
- package/bin/session/SessionMessages.d.ts +2 -1
- package/bin/session/SessionMessages.d.ts.map +1 -1
- package/bin/session/SessionMessages.js +14 -8
- package/bin/session/SessionMessages.js.map +1 -1
- package/bin/session/SessionState.d.ts +10 -9
- package/bin/session/SessionState.d.ts.map +1 -1
- package/bin/session/SessionState.js +108 -54
- package/bin/session/SessionState.js.map +1 -1
- package/bin/session/SessionTitle.d.ts +4 -0
- package/bin/session/SessionTitle.d.ts.map +1 -1
- package/bin/session/SessionTitle.js +5 -1
- package/bin/session/SessionTitle.js.map +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.d.ts +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.js +4 -4
- package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
- package/bin/session/runtime/SessionTitleTask.d.ts +35 -0
- package/bin/session/runtime/SessionTitleTask.d.ts.map +1 -0
- package/bin/session/runtime/SessionTitleTask.js +72 -0
- package/bin/session/runtime/SessionTitleTask.js.map +1 -0
- package/bin/types/sdk/AgentSessionPrompt.d.ts +22 -0
- package/bin/types/sdk/AgentSessionPrompt.d.ts.map +1 -1
- package/bin/types/sdk/AgentSessionPrompt.js.map +1 -1
- package/bin/types/session/SessionMessages.d.ts +3 -0
- package/bin/types/session/SessionMessages.d.ts.map +1 -1
- package/bin/types/session/SessionOptions.d.ts +2 -0
- package/bin/types/session/SessionOptions.d.ts.map +1 -1
- package/bin/types/store/SessionAttachmentStore.d.ts +29 -0
- package/bin/types/store/SessionAttachmentStore.d.ts.map +1 -0
- package/bin/types/store/SessionAttachmentStore.js +15 -0
- package/bin/types/store/SessionAttachmentStore.js.map +1 -0
- package/bin/types/store/SessionStore.d.ts +3 -0
- package/bin/types/store/SessionStore.d.ts.map +1 -1
- package/bin/workspace/store/LocalSessionAttachmentStore.d.ts +25 -0
- package/bin/workspace/store/LocalSessionAttachmentStore.d.ts.map +1 -0
- package/bin/workspace/store/LocalSessionAttachmentStore.js +72 -0
- package/bin/workspace/store/LocalSessionAttachmentStore.js.map +1 -0
- package/bin/workspace/store/LocalSessionStore.d.ts +3 -0
- package/bin/workspace/store/LocalSessionStore.d.ts.map +1 -1
- package/bin/workspace/store/LocalSessionStore.js +8 -1
- package/bin/workspace/store/LocalSessionStore.js.map +1 -1
- package/bin/workspace/store/LocalStorePaths.d.ts +6 -0
- package/bin/workspace/store/LocalStorePaths.d.ts.map +1 -1
- package/bin/workspace/store/LocalStorePaths.js +8 -0
- package/bin/workspace/store/LocalStorePaths.js.map +1 -1
- package/package.json +1 -1
- package/scripts/session-attachments.test.mjs +101 -0
- package/scripts/session-list-title.test.mjs +6 -0
- package/scripts/session-title-event.test.mjs +67 -3
- package/scripts/session-turn-failure.test.mjs +34 -1
- package/src/agent/Agent.ts +1 -0
- package/src/agent/AgentSessions.ts +10 -0
- package/src/executor/messages/SessionAttachmentMapper.ts +24 -37
- package/src/index.ts +1 -0
- package/src/session/Session.ts +10 -4
- package/src/session/SessionLoop.ts +2 -2
- package/src/session/SessionMessages.ts +17 -7
- package/src/session/SessionState.ts +118 -61
- package/src/session/SessionTitle.ts +13 -1
- package/src/session/messages/SessionAssistantMessageWriter.ts +7 -3
- package/src/session/runtime/SessionTitleTask.ts +86 -0
- package/src/types/sdk/AgentSessionPrompt.ts +22 -0
- package/src/types/session/SessionMessages.ts +3 -0
- package/src/types/session/SessionOptions.ts +3 -0
- package/src/types/store/SessionAttachmentStore.ts +29 -0
- package/src/types/store/SessionStore.ts +4 -0
- package/src/workspace/store/LocalSessionAttachmentStore.ts +96 -0
- package/src/workspace/store/LocalSessionStore.ts +13 -0
- package/src/workspace/store/LocalStorePaths.ts +16 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 关键点(中文)
|
|
5
5
|
* - 兼容 Telegram / Feishu / TUI 等统一的 `<file>` 协议入口。
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
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
|
|
119
|
+
* 在用户 prompt 入库前,将 Data URL file part 保存为 Session 附件路径。
|
|
136
120
|
*
|
|
137
121
|
* 关键点(中文)
|
|
138
|
-
* -
|
|
139
|
-
* -
|
|
140
|
-
* - 本地文件读取失败时直接报错,避免模型请求拿到不可访问的本地路径。
|
|
122
|
+
* - 只有 Data URL 会在此处落盘;远程 URL 和本地路径保持引用不变。
|
|
123
|
+
* - 附件成功落盘后才把 URL 替换为相对 Workspace 根目录的路径。
|
|
141
124
|
*/
|
|
142
|
-
export async function
|
|
125
|
+
export async function persist_user_prompt_file_parts(
|
|
143
126
|
parts: SessionUserMessagePart[],
|
|
144
|
-
|
|
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
|
|
157
|
-
if (!
|
|
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
|
|
163
|
-
|
|
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,
|
package/src/session/Session.ts
CHANGED
|
@@ -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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
640
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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 {
|
|
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
|
|
288
|
+
: await persist_user_prompt_file_parts(
|
|
286
289
|
Array.isArray(query) ? query : [],
|
|
287
|
-
|
|
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
|
|
771
|
-
|
|
775
|
+
part.state !== "completed" &&
|
|
776
|
+
part.state !== "failed"
|
|
772
777
|
) {
|
|
773
778
|
return {
|
|
774
779
|
...part,
|
|
775
780
|
state: "failed" as const,
|
|
776
|
-
error:
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
...
|
|
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(
|
|
562
|
+
async fail(error: unknown): Promise<void> {
|
|
563
563
|
await this.enqueue_write(async () => {
|
|
564
|
-
await this.close_serialized(
|
|
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
|
/**
|