@downcity/agent 1.1.276 → 1.1.277
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/session/messages/SessionAssistantMessageWriter.d.ts +6 -1
- package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.js +52 -15
- package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
- package/bin/session/messages/SessionToolPartGate.d.ts +23 -0
- package/bin/session/messages/SessionToolPartGate.d.ts.map +1 -0
- package/bin/session/messages/SessionToolPartGate.js +64 -0
- package/bin/session/messages/SessionToolPartGate.js.map +1 -0
- package/bin/types/session/SessionTool.d.ts +9 -0
- package/bin/types/session/SessionTool.d.ts.map +1 -1
- package/package.json +1 -1
- package/scripts/session-messages.test.mjs +180 -4
- package/scripts/session-tool-part-gate.test.mjs +58 -0
- package/src/session/messages/SessionAssistantMessageWriter.ts +65 -14
- package/src/session/messages/SessionToolPartGate.ts +67 -0
- package/src/types/session/SessionTool.ts +10 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -49,6 +49,24 @@ class PausingAssistantMessageStore extends JsonlSessionMessageStore {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** 可让下一次 Assistant 草稿更新失败,用于验证持久化失败边界。 */
|
|
53
|
+
class FailingAssistantMessageStore extends JsonlSessionMessageStore {
|
|
54
|
+
next_assistant_error = null;
|
|
55
|
+
|
|
56
|
+
fail_next_assistant_write(message) {
|
|
57
|
+
this.next_assistant_error = new Error(message);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async write_assistant_message(message) {
|
|
61
|
+
const error = this.next_assistant_error;
|
|
62
|
+
if (error) {
|
|
63
|
+
this.next_assistant_error = null;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
await super.write_assistant_message(message);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
async function create_recorder(
|
|
53
71
|
session_id = "session-recorder-test",
|
|
54
72
|
create_store = (options) => new JsonlSessionMessageStore(options),
|
|
@@ -703,7 +721,7 @@ test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
|
|
|
703
721
|
);
|
|
704
722
|
assert.equal(approval_broker.list().length, 0);
|
|
705
723
|
|
|
706
|
-
|
|
724
|
+
const tool_input_ready = writer.prepare_tool_input({
|
|
707
725
|
tool_call_id: "call-ready-barrier",
|
|
708
726
|
tool_name: "shell_exec",
|
|
709
727
|
input: {
|
|
@@ -712,6 +730,14 @@ test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
|
|
|
712
730
|
reason: "Inspect requested desktop files",
|
|
713
731
|
},
|
|
714
732
|
});
|
|
733
|
+
await writer.flush();
|
|
734
|
+
assert.deepEqual(recorder.get_message(writer.message_id).parts, []);
|
|
735
|
+
await writer.apply_chunk({
|
|
736
|
+
type: "tool-input-start",
|
|
737
|
+
toolCallId: "call-ready-barrier",
|
|
738
|
+
toolName: "shell_exec",
|
|
739
|
+
});
|
|
740
|
+
await tool_input_ready;
|
|
715
741
|
const approval_handle = await approval_broker.request(approval_input);
|
|
716
742
|
|
|
717
743
|
const tool = recorder.get_message(writer.message_id).parts[0];
|
|
@@ -734,7 +760,7 @@ test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
|
|
|
734
760
|
events
|
|
735
761
|
.filter((event) => event.variant === "part" && event.type === "tool")
|
|
736
762
|
.map((event) => event.part.state),
|
|
737
|
-
["ready", "approval-required", "failed"],
|
|
763
|
+
["input-streaming", "ready", "approval-required", "failed"],
|
|
738
764
|
);
|
|
739
765
|
});
|
|
740
766
|
|
|
@@ -749,6 +775,11 @@ test("流式更新与 Approval 共享 Assistant revision 写队列", async () =>
|
|
|
749
775
|
turn_id: "turn-approval-race",
|
|
750
776
|
segment_index: 1,
|
|
751
777
|
});
|
|
778
|
+
await writer.apply_chunk({
|
|
779
|
+
type: "tool-input-start",
|
|
780
|
+
toolCallId: "call-approval-race",
|
|
781
|
+
toolName: "shell_exec",
|
|
782
|
+
});
|
|
752
783
|
await writer.prepare_tool_input({
|
|
753
784
|
tool_call_id: "call-approval-race",
|
|
754
785
|
tool_name: "shell_exec",
|
|
@@ -789,7 +820,7 @@ test("流式更新与 Approval 共享 Assistant revision 写队列", async () =>
|
|
|
789
820
|
const assistant = recorder.get_message(writer.message_id);
|
|
790
821
|
const tool = assistant.parts.find((part) => part.type === "tool");
|
|
791
822
|
const text_part = assistant.parts.find((part) => part.type === "text");
|
|
792
|
-
assert.equal(assistant.revision,
|
|
823
|
+
assert.equal(assistant.revision, 7);
|
|
793
824
|
assert.equal(tool.state, "approval-required");
|
|
794
825
|
assert.equal(tool.approval.approval_id, approval_handle.approval_id);
|
|
795
826
|
assert.equal(tool.input.cmd, "ls -la ~");
|
|
@@ -798,7 +829,7 @@ test("流式更新与 Approval 共享 Assistant revision 写队列", async () =>
|
|
|
798
829
|
events
|
|
799
830
|
.filter((event) => event.message_id === writer.message_id)
|
|
800
831
|
.map((event) => event.revision),
|
|
801
|
-
[1, 2, 3, 4, 5, 6],
|
|
832
|
+
[1, 2, 3, 4, 5, 6, 7],
|
|
802
833
|
);
|
|
803
834
|
assert.equal(
|
|
804
835
|
events.some(
|
|
@@ -1017,6 +1048,151 @@ test("空 Text Start 不会抢占后续 Tool 的真实顺序", async () => {
|
|
|
1017
1048
|
);
|
|
1018
1049
|
});
|
|
1019
1050
|
|
|
1051
|
+
test("Tool 执行先到时等待 stream 固定 canonical Part 顺序", async () => {
|
|
1052
|
+
const { recorder, file_path } = await create_recorder("pending-tool-input-order-test");
|
|
1053
|
+
const writer = await recorder.open_assistant_message({
|
|
1054
|
+
turn_id: "turn-pending-tool-input",
|
|
1055
|
+
segment_index: 1,
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
await writer.begin_step();
|
|
1059
|
+
const tool_input_ready = writer.prepare_tool_input({
|
|
1060
|
+
tool_call_id: "call-pending",
|
|
1061
|
+
tool_name: "search",
|
|
1062
|
+
input: { query: "downcity" },
|
|
1063
|
+
});
|
|
1064
|
+
await writer.flush();
|
|
1065
|
+
assert.deepEqual(recorder.get_message(writer.message_id).parts, []);
|
|
1066
|
+
|
|
1067
|
+
await writer.apply_chunk({ type: "text-start", id: "text-0" });
|
|
1068
|
+
await writer.apply_chunk({ type: "text-delta", id: "text-0", delta: "第一段" });
|
|
1069
|
+
await writer.apply_chunk({ type: "text-end", id: "text-0" });
|
|
1070
|
+
await writer.apply_chunk({ type: "text-start", id: "text-1" });
|
|
1071
|
+
await writer.apply_chunk({ type: "text-delta", id: "text-1", delta: "第二段" });
|
|
1072
|
+
await writer.apply_chunk({ type: "text-end", id: "text-1" });
|
|
1073
|
+
await writer.apply_chunk({
|
|
1074
|
+
type: "tool-input-start",
|
|
1075
|
+
toolCallId: "call-pending",
|
|
1076
|
+
toolName: "search",
|
|
1077
|
+
});
|
|
1078
|
+
await tool_input_ready;
|
|
1079
|
+
await writer.apply_chunk({
|
|
1080
|
+
type: "tool-input-available",
|
|
1081
|
+
toolCallId: "call-pending",
|
|
1082
|
+
toolName: "search",
|
|
1083
|
+
input: { query: "downcity" },
|
|
1084
|
+
});
|
|
1085
|
+
await writer.apply_chunk({
|
|
1086
|
+
type: "tool-output-available",
|
|
1087
|
+
toolCallId: "call-pending",
|
|
1088
|
+
output: "result",
|
|
1089
|
+
});
|
|
1090
|
+
await writer.finish_step([
|
|
1091
|
+
{
|
|
1092
|
+
part_id: "text-final-0",
|
|
1093
|
+
sequence: 1,
|
|
1094
|
+
type: "text",
|
|
1095
|
+
text: "第一段",
|
|
1096
|
+
state: "done",
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
part_id: "text-final-1",
|
|
1100
|
+
sequence: 2,
|
|
1101
|
+
type: "text",
|
|
1102
|
+
text: "第二段",
|
|
1103
|
+
state: "done",
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
part_id: "call-pending",
|
|
1107
|
+
sequence: 3,
|
|
1108
|
+
type: "tool",
|
|
1109
|
+
tool_call_id: "call-pending",
|
|
1110
|
+
tool_name: "search",
|
|
1111
|
+
state: "completed",
|
|
1112
|
+
input: { query: "downcity" },
|
|
1113
|
+
output: "result",
|
|
1114
|
+
},
|
|
1115
|
+
]);
|
|
1116
|
+
await writer.complete();
|
|
1117
|
+
|
|
1118
|
+
const assistant = (await read_jsonl(file_path))[0];
|
|
1119
|
+
assert.deepEqual(assistant.parts.map((part) => part.type), ["text", "text", "tool"]);
|
|
1120
|
+
assert.deepEqual(assistant.parts.map((part) => part.sequence), [1, 2, 3]);
|
|
1121
|
+
assert.deepEqual(assistant.parts[2].input, { query: "downcity" });
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
test("step abort 与 writer close 会拒绝未释放的 Tool 输入屏障", async () => {
|
|
1125
|
+
const { recorder } = await create_recorder("pending-tool-input-cleanup-test");
|
|
1126
|
+
const aborted_writer = await recorder.open_assistant_message({
|
|
1127
|
+
turn_id: "turn-aborted-tool-input",
|
|
1128
|
+
segment_index: 1,
|
|
1129
|
+
});
|
|
1130
|
+
await aborted_writer.begin_step();
|
|
1131
|
+
const aborted_input = assert.rejects(
|
|
1132
|
+
aborted_writer.prepare_tool_input({
|
|
1133
|
+
tool_call_id: "call-aborted",
|
|
1134
|
+
tool_name: "search",
|
|
1135
|
+
input: { query: "aborted" },
|
|
1136
|
+
}),
|
|
1137
|
+
/canonical step was aborted: call-aborted/,
|
|
1138
|
+
);
|
|
1139
|
+
await aborted_writer.flush();
|
|
1140
|
+
await aborted_writer.abort_step();
|
|
1141
|
+
await aborted_input;
|
|
1142
|
+
await aborted_writer.fail("aborted");
|
|
1143
|
+
|
|
1144
|
+
const closed_writer = await recorder.open_assistant_message({
|
|
1145
|
+
turn_id: "turn-closed-tool-input",
|
|
1146
|
+
segment_index: 2,
|
|
1147
|
+
});
|
|
1148
|
+
const closed_input = assert.rejects(
|
|
1149
|
+
closed_writer.prepare_tool_input({
|
|
1150
|
+
tool_call_id: "call-closed",
|
|
1151
|
+
tool_name: "search",
|
|
1152
|
+
input: { query: "closed" },
|
|
1153
|
+
}),
|
|
1154
|
+
/writer closed with status stopped: call-closed/,
|
|
1155
|
+
);
|
|
1156
|
+
await closed_writer.flush();
|
|
1157
|
+
await closed_writer.stop();
|
|
1158
|
+
await closed_input;
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
test("Tool Part 持久化失败时不会释放对应执行等待", async () => {
|
|
1162
|
+
const { recorder, store } = await create_recorder(
|
|
1163
|
+
"tool-part-persistence-gate-test",
|
|
1164
|
+
(options) => new FailingAssistantMessageStore(options),
|
|
1165
|
+
);
|
|
1166
|
+
const writer = await recorder.open_assistant_message({
|
|
1167
|
+
turn_id: "turn-tool-part-persistence",
|
|
1168
|
+
segment_index: 1,
|
|
1169
|
+
});
|
|
1170
|
+
await writer.begin_step();
|
|
1171
|
+
const tool_input = assert.rejects(
|
|
1172
|
+
writer.prepare_tool_input({
|
|
1173
|
+
tool_call_id: "call-persistence-failed",
|
|
1174
|
+
tool_name: "search",
|
|
1175
|
+
input: { query: "downcity" },
|
|
1176
|
+
}),
|
|
1177
|
+
/canonical step was aborted: call-persistence-failed/,
|
|
1178
|
+
);
|
|
1179
|
+
|
|
1180
|
+
store.fail_next_assistant_write("disk full");
|
|
1181
|
+
await assert.rejects(
|
|
1182
|
+
writer.apply_chunk({
|
|
1183
|
+
type: "tool-input-start",
|
|
1184
|
+
toolCallId: "call-persistence-failed",
|
|
1185
|
+
toolName: "search",
|
|
1186
|
+
}),
|
|
1187
|
+
/disk full/,
|
|
1188
|
+
);
|
|
1189
|
+
assert.deepEqual(recorder.get_message(writer.message_id).parts, []);
|
|
1190
|
+
|
|
1191
|
+
await writer.abort_step();
|
|
1192
|
+
await tool_input;
|
|
1193
|
+
await writer.fail("disk full");
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1020
1196
|
test("step 最终快照缺少 canonical Tool chunk 时拒绝猜测顺序", async () => {
|
|
1021
1197
|
const { recorder, events, file_path } = await create_recorder("final-reconcile-order-test");
|
|
1022
1198
|
const writer = await recorder.open_assistant_message({ turn_id: "turn-1", segment_index: 1 });
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file 验证 canonical Tool Part Gate 按 Tool Call 独立协调等待与释放。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
|
|
8
|
+
import { SessionToolPartGate } from "../bin/session/messages/SessionToolPartGate.js";
|
|
9
|
+
|
|
10
|
+
test("不同 Tool Call 独立释放,不形成全局执行锁", async () => {
|
|
11
|
+
const gate = new SessionToolPartGate();
|
|
12
|
+
let tool_a_ready = false;
|
|
13
|
+
let tool_b_ready = false;
|
|
14
|
+
const tool_a = gate.wait_until_available("call-a").then(() => {
|
|
15
|
+
tool_a_ready = true;
|
|
16
|
+
});
|
|
17
|
+
const tool_b = gate.wait_until_available("call-b").then(() => {
|
|
18
|
+
tool_b_ready = true;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
gate.mark_available("call-a");
|
|
22
|
+
await tool_a;
|
|
23
|
+
assert.equal(tool_a_ready, true);
|
|
24
|
+
assert.equal(tool_b_ready, false);
|
|
25
|
+
|
|
26
|
+
gate.mark_available("call-b");
|
|
27
|
+
await tool_b;
|
|
28
|
+
assert.equal(tool_b_ready, true);
|
|
29
|
+
await gate.wait_until_available("call-a");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("reject_pending 只清理当前等待,Gate 可用于后续 step", async () => {
|
|
33
|
+
const gate = new SessionToolPartGate();
|
|
34
|
+
const aborted = assert.rejects(
|
|
35
|
+
gate.wait_until_available("call-aborted"),
|
|
36
|
+
/step aborted: call-aborted/,
|
|
37
|
+
);
|
|
38
|
+
gate.reject_pending("step aborted");
|
|
39
|
+
await aborted;
|
|
40
|
+
|
|
41
|
+
const next_step = gate.wait_until_available("call-next");
|
|
42
|
+
gate.mark_available("call-next");
|
|
43
|
+
await next_step;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("close 拒绝当前及后续 Tool Part 等待", async () => {
|
|
47
|
+
const gate = new SessionToolPartGate();
|
|
48
|
+
const pending = assert.rejects(
|
|
49
|
+
gate.wait_until_available("call-pending"),
|
|
50
|
+
/writer closed: call-pending/,
|
|
51
|
+
);
|
|
52
|
+
gate.close("writer closed");
|
|
53
|
+
await pending;
|
|
54
|
+
await assert.rejects(
|
|
55
|
+
gate.wait_until_available("call-future"),
|
|
56
|
+
/writer closed/,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
to_session_json_value,
|
|
13
13
|
to_session_provider_metadata,
|
|
14
14
|
} from "@/session/messages/SessionJsonValue.js";
|
|
15
|
+
import { SessionToolPartGate } from "@/session/messages/SessionToolPartGate.js";
|
|
15
16
|
import type {
|
|
16
17
|
SessionAssistantFilePart,
|
|
17
18
|
SessionAssistantMessage,
|
|
@@ -32,6 +33,7 @@ export class SessionAssistantMessageWriter {
|
|
|
32
33
|
>();
|
|
33
34
|
private readonly active_text_part_ids = new Map<string, string>();
|
|
34
35
|
private readonly current_step_part_ids = new Set<string>();
|
|
36
|
+
private readonly tool_part_gate = new SessionToolPartGate();
|
|
35
37
|
private write_chain: Promise<void> = Promise.resolve();
|
|
36
38
|
private step_index = 0;
|
|
37
39
|
private step_active = false;
|
|
@@ -108,8 +110,8 @@ export class SessionAssistantMessageWriter {
|
|
|
108
110
|
/** 释放异常结束的 step 作用域并保留已经写入的 canonical Parts。 */
|
|
109
111
|
async abort_step(): Promise<void> {
|
|
110
112
|
await this.enqueue_write(async () => {
|
|
111
|
-
|
|
112
|
-
this.reset_step_state();
|
|
113
|
+
this.tool_part_gate.reject_pending("Assistant canonical step was aborted");
|
|
114
|
+
if (this.step_active) this.reset_step_state();
|
|
113
115
|
});
|
|
114
116
|
}
|
|
115
117
|
|
|
@@ -196,7 +198,7 @@ export class SessionAssistantMessageWriter {
|
|
|
196
198
|
});
|
|
197
199
|
return;
|
|
198
200
|
}
|
|
199
|
-
await this.
|
|
201
|
+
await this.create_tool(chunk.toolCallId, {
|
|
200
202
|
tool_name: chunk.toolName,
|
|
201
203
|
state: "input-streaming",
|
|
202
204
|
input_text: "",
|
|
@@ -210,6 +212,7 @@ export class SessionAssistantMessageWriter {
|
|
|
210
212
|
...(tool_metadata !== undefined ? { tool_metadata } : {}),
|
|
211
213
|
...(chunk.dynamic !== undefined ? { dynamic: chunk.dynamic } : {}),
|
|
212
214
|
});
|
|
215
|
+
this.tool_part_gate.mark_available(chunk.toolCallId);
|
|
213
216
|
return;
|
|
214
217
|
}
|
|
215
218
|
case "tool-input-delta": {
|
|
@@ -251,7 +254,7 @@ export class SessionAssistantMessageWriter {
|
|
|
251
254
|
});
|
|
252
255
|
return;
|
|
253
256
|
}
|
|
254
|
-
|
|
257
|
+
const changes = {
|
|
255
258
|
tool_name: chunk.toolName,
|
|
256
259
|
state: "ready",
|
|
257
260
|
input: to_session_json_value(chunk.input),
|
|
@@ -264,7 +267,13 @@ export class SessionAssistantMessageWriter {
|
|
|
264
267
|
...(chunk.title !== undefined ? { title: chunk.title } : {}),
|
|
265
268
|
...(tool_metadata !== undefined ? { tool_metadata } : {}),
|
|
266
269
|
...(chunk.dynamic !== undefined ? { dynamic: chunk.dynamic } : {}),
|
|
267
|
-
}
|
|
270
|
+
} as const;
|
|
271
|
+
if (tool) {
|
|
272
|
+
await this.upsert_tool(chunk.toolCallId, changes);
|
|
273
|
+
} else {
|
|
274
|
+
await this.create_tool(chunk.toolCallId, changes);
|
|
275
|
+
this.tool_part_gate.mark_available(chunk.toolCallId);
|
|
276
|
+
}
|
|
268
277
|
return;
|
|
269
278
|
}
|
|
270
279
|
case "tool-input-error": {
|
|
@@ -449,18 +458,21 @@ export class SessionAssistantMessageWriter {
|
|
|
449
458
|
|
|
450
459
|
/** Executor 在调用 Tool 实现前写入完整输入。 */
|
|
451
460
|
async prepare_tool_input(input: SessionToolInputReady): Promise<void> {
|
|
461
|
+
await this.tool_part_gate.wait_until_available(input.tool_call_id);
|
|
452
462
|
await this.enqueue_write(async () => {
|
|
463
|
+
if (this.closed) throw new Error("Assistant Message writer is closed");
|
|
453
464
|
const current = this.find_tool(input.tool_call_id);
|
|
454
|
-
if (current
|
|
465
|
+
if (!current) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`Assistant canonical Tool Part not found: ${input.tool_call_id}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (current.state !== "input-streaming" && current.state !== "ready") {
|
|
455
471
|
throw new Error(
|
|
456
472
|
`Tool input cannot be prepared from ${current.state}: ${input.tool_call_id}`,
|
|
457
473
|
);
|
|
458
474
|
}
|
|
459
|
-
await this.
|
|
460
|
-
tool_name: input.tool_name,
|
|
461
|
-
state: "ready",
|
|
462
|
-
input: to_session_json_value(input.input),
|
|
463
|
-
});
|
|
475
|
+
await this.write_prepared_tool_input(input);
|
|
464
476
|
});
|
|
465
477
|
}
|
|
466
478
|
|
|
@@ -692,23 +704,59 @@ export class SessionAssistantMessageWriter {
|
|
|
692
704
|
this.pending_text_parts.delete(part_id);
|
|
693
705
|
}
|
|
694
706
|
|
|
695
|
-
/**
|
|
707
|
+
/** 更新已经由 canonical stream 创建的 Tool Part。 */
|
|
696
708
|
private async upsert_tool(
|
|
697
709
|
tool_call_id: string,
|
|
698
710
|
changes: Pick<SessionAssistantToolPart, "tool_name" | "state"> &
|
|
699
711
|
Partial<Omit<SessionAssistantToolPart, "part_id" | "type" | "tool_call_id" | "tool_name" | "state">>,
|
|
700
712
|
): Promise<void> {
|
|
701
713
|
const current = this.find_tool(tool_call_id);
|
|
714
|
+
if (!current) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
`Assistant canonical Tool Part not found: ${tool_call_id}`,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
702
719
|
await this.upsert_part({
|
|
703
|
-
...
|
|
720
|
+
...current,
|
|
704
721
|
part_id: `tool:${tool_call_id}`,
|
|
705
|
-
sequence: current
|
|
722
|
+
sequence: current.sequence,
|
|
706
723
|
type: "tool",
|
|
707
724
|
tool_call_id,
|
|
708
725
|
...changes,
|
|
709
726
|
});
|
|
710
727
|
}
|
|
711
728
|
|
|
729
|
+
/** 仅由 Tool 输入 stream chunk 创建 canonical Tool Part。 */
|
|
730
|
+
private async create_tool(
|
|
731
|
+
tool_call_id: string,
|
|
732
|
+
changes: Pick<SessionAssistantToolPart, "tool_name" | "state"> &
|
|
733
|
+
Partial<Omit<SessionAssistantToolPart, "part_id" | "type" | "tool_call_id" | "tool_name" | "state">>,
|
|
734
|
+
): Promise<void> {
|
|
735
|
+
if (this.find_tool(tool_call_id)) {
|
|
736
|
+
throw new Error(
|
|
737
|
+
`Assistant canonical Tool Part already exists: ${tool_call_id}`,
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
await this.upsert_part({
|
|
741
|
+
part_id: `tool:${tool_call_id}`,
|
|
742
|
+
sequence: this.next_part_sequence(),
|
|
743
|
+
type: "tool",
|
|
744
|
+
tool_call_id,
|
|
745
|
+
...changes,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** 将 Executor 完整输入写入已经由 stream 创建的 Tool Part。 */
|
|
750
|
+
private async write_prepared_tool_input(
|
|
751
|
+
input: SessionToolInputReady,
|
|
752
|
+
): Promise<void> {
|
|
753
|
+
await this.upsert_tool(input.tool_call_id, {
|
|
754
|
+
tool_name: input.tool_name,
|
|
755
|
+
state: "ready",
|
|
756
|
+
input: to_session_json_value(input.input),
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
712
760
|
/** 串行执行对当前 Assistant Message 的全部写操作。 */
|
|
713
761
|
private async enqueue_write(operation: () => Promise<void>): Promise<void> {
|
|
714
762
|
const current = this.write_chain.then(operation, operation);
|
|
@@ -721,6 +769,9 @@ export class SessionAssistantMessageWriter {
|
|
|
721
769
|
status: "completed" | "stopped" | "failed",
|
|
722
770
|
): Promise<void> {
|
|
723
771
|
if (this.closed) return;
|
|
772
|
+
this.tool_part_gate.close(
|
|
773
|
+
`Assistant Message writer closed with status ${status}`,
|
|
774
|
+
);
|
|
724
775
|
this.reset_step_state();
|
|
725
776
|
await this.recorder.complete_assistant_message(this.message_id, status);
|
|
726
777
|
this.closed = true;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canonical Tool Part 的并发等待门。
|
|
3
|
+
*
|
|
4
|
+
* Gate 只协调单个 `tool_call_id` 的可用状态,不接触 Message、Recorder 或
|
|
5
|
+
* Tool 输入。不同 Tool 使用独立 Promise,因此不会形成全局执行锁。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SessionToolPartWaiter } from "@/types/session/SessionTool.js";
|
|
9
|
+
|
|
10
|
+
/** 按 Tool Call 隔离的 canonical Part 等待门。 */
|
|
11
|
+
export class SessionToolPartGate {
|
|
12
|
+
private readonly available_tool_call_ids = new Set<string>();
|
|
13
|
+
private readonly pending_waiters = new Map<string, SessionToolPartWaiter>();
|
|
14
|
+
private closed_error: Error | null = null;
|
|
15
|
+
|
|
16
|
+
/** 等待指定 Tool Part 成功持久化;已经可用时立即完成。 */
|
|
17
|
+
wait_until_available(tool_call_id: string): Promise<void> {
|
|
18
|
+
if (this.closed_error) return Promise.reject(this.closed_error);
|
|
19
|
+
if (this.available_tool_call_ids.has(tool_call_id)) return Promise.resolve();
|
|
20
|
+
|
|
21
|
+
const current = this.pending_waiters.get(tool_call_id);
|
|
22
|
+
if (current) return current.promise;
|
|
23
|
+
|
|
24
|
+
const waiter = this.create_waiter();
|
|
25
|
+
this.pending_waiters.set(tool_call_id, waiter);
|
|
26
|
+
return waiter.promise;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 标记指定 Tool Part 已持久化,并只释放该 Tool 的等待者。 */
|
|
30
|
+
mark_available(tool_call_id: string): void {
|
|
31
|
+
if (this.closed_error) throw this.closed_error;
|
|
32
|
+
if (this.available_tool_call_ids.has(tool_call_id)) return;
|
|
33
|
+
|
|
34
|
+
this.available_tool_call_ids.add(tool_call_id);
|
|
35
|
+
const waiter = this.pending_waiters.get(tool_call_id);
|
|
36
|
+
if (!waiter) return;
|
|
37
|
+
this.pending_waiters.delete(tool_call_id);
|
|
38
|
+
waiter.resolve();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 拒绝当前尚未出现 canonical Part 的全部等待者。 */
|
|
42
|
+
reject_pending(reason: string): void {
|
|
43
|
+
for (const [tool_call_id, waiter] of this.pending_waiters) {
|
|
44
|
+
waiter.reject(new Error(`${reason}: ${tool_call_id}`));
|
|
45
|
+
}
|
|
46
|
+
this.pending_waiters.clear();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 永久关闭 Gate,并拒绝当前及后续等待。 */
|
|
50
|
+
close(reason: string): void {
|
|
51
|
+
if (this.closed_error) return;
|
|
52
|
+
this.closed_error = new Error(reason);
|
|
53
|
+
this.reject_pending(reason);
|
|
54
|
+
this.available_tool_call_ids.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 创建单个 Tool Call 使用的异步等待句柄。 */
|
|
58
|
+
private create_waiter(): SessionToolPartWaiter {
|
|
59
|
+
let resolve!: () => void;
|
|
60
|
+
let reject!: (error: Error) => void;
|
|
61
|
+
const promise = new Promise<void>((resolve_promise, reject_promise) => {
|
|
62
|
+
resolve = resolve_promise;
|
|
63
|
+
reject = reject_promise;
|
|
64
|
+
});
|
|
65
|
+
return { promise, resolve, reject };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -23,3 +23,13 @@ export interface SessionToolInputReady {
|
|
|
23
23
|
/** 已完成解析的 Tool 输入。 */
|
|
24
24
|
input: unknown;
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
/** 等待单个 canonical Tool Part 到达的异步句柄。 */
|
|
28
|
+
export interface SessionToolPartWaiter {
|
|
29
|
+
/** Tool Part 成功持久化后完成,异常结束时拒绝。 */
|
|
30
|
+
promise: Promise<void>;
|
|
31
|
+
/** Tool Part 已按 canonical 顺序持久化时释放等待。 */
|
|
32
|
+
resolve: () => void;
|
|
33
|
+
/** 当前 step 或 Gate 异常结束时拒绝等待。 */
|
|
34
|
+
reject: (error: Error) => void;
|
|
35
|
+
}
|