wtagent 0.3.0 → 0.4.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.
- package/README.md +2 -2
- package/docs/technical-design.md +18 -11
- package/package.json +5 -5
- package/src/browser/base-web-adapter.js +2081 -168
- package/src/browser/cdp-browser.js +223 -25
- package/src/browser/cdp-state.js +8 -5
- package/src/browser/chatgpt-web-adapter.js +799 -1
- package/src/browser/claude-web-adapter.js +13 -0
- package/src/browser/fake-web-model-adapter.js +137 -3
- package/src/browser/gemini-web-adapter.js +10 -0
- package/src/browser/glm-web-adapter.js +2 -2
- package/src/cli/i18n.js +10 -10
- package/src/cli/main.js +94 -39
- package/src/platform/command-launcher.js +1 -1
- package/src/platform/paths.js +19 -0
- package/src/platform/windows-diagnostics.js +9 -16
- package/src/policy/path-guard.js +5 -1
- package/src/runtime/agent-runtime.js +1056 -191
- package/src/session/agent-session.js +975 -120
- package/src/tools/default-tools.js +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
7
|
import { isPathInside } from "../policy/path-guard.js";
|
|
8
|
+
import { processStartTimeMs } from "../browser/cdp-state.js";
|
|
6
9
|
import { replaceFileAtomic } from "../shared/atomic-write.js";
|
|
7
10
|
|
|
8
11
|
const DIRECTORY_MODE = 0o700;
|
|
@@ -10,6 +13,330 @@ const FILE_MODE = 0o600;
|
|
|
10
13
|
const NO_FOLLOW = process.platform === "win32"
|
|
11
14
|
? 0
|
|
12
15
|
: (fsConstants.O_NOFOLLOW ?? 0);
|
|
16
|
+
const STATE_LOCK_FILE = ".wtagent-state.lock";
|
|
17
|
+
const persistenceQueues = new Map();
|
|
18
|
+
|
|
19
|
+
function stateRevision(state) {
|
|
20
|
+
return Number.isSafeInteger(state?.stateRevision)
|
|
21
|
+
&& state.stateRevision >= 0
|
|
22
|
+
? state.stateRevision
|
|
23
|
+
: 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sessionStateConflict(message) {
|
|
27
|
+
const error = new Error(message);
|
|
28
|
+
error.code = "SESSION_STATE_CONFLICT";
|
|
29
|
+
return error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function processIsAlive(pid) {
|
|
33
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return error.code === "EPERM";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const STATE_LOCK_MUTEX_PREFIX = "WTAGENT_STATE_LOCK_V1 ";
|
|
45
|
+
|
|
46
|
+
async function openPersistenceLockServer({ port = 0, token }) {
|
|
47
|
+
let responseToken = token;
|
|
48
|
+
const server = net.createServer((socket) => {
|
|
49
|
+
socket.end(`${STATE_LOCK_MUTEX_PREFIX}${responseToken}\n`);
|
|
50
|
+
});
|
|
51
|
+
server.unref();
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
server.once("error", reject);
|
|
54
|
+
server.once("listening", resolve);
|
|
55
|
+
server.listen({
|
|
56
|
+
host: "127.0.0.1",
|
|
57
|
+
port,
|
|
58
|
+
exclusive: true,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
const address = server.address();
|
|
62
|
+
if (!address || typeof address === "string") {
|
|
63
|
+
await new Promise((resolve) => server.close(resolve));
|
|
64
|
+
throw new Error("Could not determine the WTAgent state-lock mutex port.");
|
|
65
|
+
}
|
|
66
|
+
let closed = false;
|
|
67
|
+
return {
|
|
68
|
+
port: address.port,
|
|
69
|
+
setToken(value) {
|
|
70
|
+
responseToken = value;
|
|
71
|
+
},
|
|
72
|
+
async close() {
|
|
73
|
+
if (closed) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
closed = true;
|
|
77
|
+
await new Promise((resolve) => {
|
|
78
|
+
server.close(() => resolve());
|
|
79
|
+
server.closeAllConnections?.();
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function persistenceLockMutexPorts(directory) {
|
|
86
|
+
const resolved = path.resolve(directory);
|
|
87
|
+
const ports = new Set();
|
|
88
|
+
for (let index = 0; ports.size < 5; index += 1) {
|
|
89
|
+
const digest = createHash("sha256")
|
|
90
|
+
.update(`${resolved}\0state-lock\0${index}`)
|
|
91
|
+
.digest();
|
|
92
|
+
ports.add(41_000 + (digest.readUInt16BE(0) % 20_000));
|
|
93
|
+
}
|
|
94
|
+
return [...ports];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function acquirePersistenceLockMutex(
|
|
98
|
+
directory,
|
|
99
|
+
token,
|
|
100
|
+
{ timeoutMs = 2_000 } = {},
|
|
101
|
+
) {
|
|
102
|
+
const mutexPorts = persistenceLockMutexPorts(directory);
|
|
103
|
+
const majority = Math.floor(mutexPorts.length / 2) + 1;
|
|
104
|
+
const deadline = Date.now() + timeoutMs;
|
|
105
|
+
let lastError = null;
|
|
106
|
+
|
|
107
|
+
while (Date.now() < deadline) {
|
|
108
|
+
const offset = Math.floor(Math.random() * mutexPorts.length);
|
|
109
|
+
const orderedPorts = mutexPorts.map(
|
|
110
|
+
(_, index) => mutexPorts[(index + offset) % mutexPorts.length],
|
|
111
|
+
);
|
|
112
|
+
const servers = [];
|
|
113
|
+
try {
|
|
114
|
+
for (const port of orderedPorts) {
|
|
115
|
+
try {
|
|
116
|
+
servers.push(await openPersistenceLockServer({ port, token }));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error.code !== "EADDRINUSE") {
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
lastError = error;
|
|
122
|
+
}
|
|
123
|
+
if (servers.length >= majority) {
|
|
124
|
+
let closed = false;
|
|
125
|
+
return {
|
|
126
|
+
mutexPorts,
|
|
127
|
+
heldMutexPorts: servers.map((server) => server.port),
|
|
128
|
+
async close() {
|
|
129
|
+
if (closed) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
closed = true;
|
|
133
|
+
await Promise.all(servers.map((server) => server.close()));
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
if (servers.length < majority) {
|
|
140
|
+
await Promise.all(servers.map((server) => server.close().catch(() => null)));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, 5 + Math.floor(Math.random() * 11)));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const error = new Error(
|
|
147
|
+
`Could not acquire the WTAgent session lock mutex: ${lastError?.message ?? "timeout"}`,
|
|
148
|
+
);
|
|
149
|
+
error.code = "SESSION_STATE_LOCKED";
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function writePersistenceLockCandidate(candidatePath, {
|
|
154
|
+
token,
|
|
155
|
+
mutexPorts,
|
|
156
|
+
heldMutexPorts,
|
|
157
|
+
}) {
|
|
158
|
+
const handle = await fs.open(
|
|
159
|
+
candidatePath,
|
|
160
|
+
fsConstants.O_WRONLY
|
|
161
|
+
| fsConstants.O_CREAT
|
|
162
|
+
| fsConstants.O_EXCL
|
|
163
|
+
| NO_FOLLOW,
|
|
164
|
+
FILE_MODE,
|
|
165
|
+
);
|
|
166
|
+
try {
|
|
167
|
+
await handle.writeFile(`${JSON.stringify({
|
|
168
|
+
pid: process.pid,
|
|
169
|
+
token,
|
|
170
|
+
mutexPorts,
|
|
171
|
+
heldMutexPorts,
|
|
172
|
+
createdAt: new Date().toISOString(),
|
|
173
|
+
})}\n`, "utf8");
|
|
174
|
+
if (process.platform !== "win32") {
|
|
175
|
+
await handle.chmod(FILE_MODE);
|
|
176
|
+
}
|
|
177
|
+
await handle.sync();
|
|
178
|
+
} finally {
|
|
179
|
+
await handle.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function readPersistenceLock(filePath) {
|
|
184
|
+
try {
|
|
185
|
+
await assertSafeFile(filePath);
|
|
186
|
+
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error.code === "ENOENT" || error instanceof SyntaxError) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function acquirePersistenceLock(
|
|
196
|
+
directory,
|
|
197
|
+
{ timeoutMs = 2_000 } = {},
|
|
198
|
+
) {
|
|
199
|
+
const filePath = path.join(directory, STATE_LOCK_FILE);
|
|
200
|
+
const token = randomUUID();
|
|
201
|
+
const candidatePath = `${filePath}.${process.pid}.${token}.tmp`;
|
|
202
|
+
const deadline = Date.now() + timeoutMs;
|
|
203
|
+
const lockMutex = await acquirePersistenceLockMutex(
|
|
204
|
+
directory,
|
|
205
|
+
token,
|
|
206
|
+
{ timeoutMs },
|
|
207
|
+
);
|
|
208
|
+
let acquired = false;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await writePersistenceLockCandidate(candidatePath, {
|
|
212
|
+
token,
|
|
213
|
+
mutexPorts: lockMutex.mutexPorts,
|
|
214
|
+
heldMutexPorts: lockMutex.heldMutexPorts,
|
|
215
|
+
});
|
|
216
|
+
while (Date.now() < deadline) {
|
|
217
|
+
try {
|
|
218
|
+
// A fully initialized inode wins an uncontended lock with one atomic link.
|
|
219
|
+
await fs.link(candidatePath, filePath);
|
|
220
|
+
break;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error.code !== "EEXIST") {
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
const existing = await readPersistenceLock(filePath);
|
|
226
|
+
if (!existing) {
|
|
227
|
+
const stillExists = await fs.stat(filePath)
|
|
228
|
+
.then(() => true)
|
|
229
|
+
.catch((statError) => {
|
|
230
|
+
if (statError.code === "ENOENT") {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
throw statError;
|
|
234
|
+
});
|
|
235
|
+
if (!stillExists) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const lockError = new Error(
|
|
239
|
+
`The WTAgent session lock at ${filePath} is malformed; refusing to replace it without a verifiable owner.`,
|
|
240
|
+
);
|
|
241
|
+
lockError.code = "SESSION_STATE_LOCKED";
|
|
242
|
+
throw lockError;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const existingPid = Number(existing.pid);
|
|
246
|
+
const validExisting = Number.isSafeInteger(existingPid)
|
|
247
|
+
&& existingPid > 0
|
|
248
|
+
&& typeof existing.token === "string"
|
|
249
|
+
&& existing.token.length > 0
|
|
250
|
+
&& Number.isFinite(Date.parse(existing.createdAt ?? ""));
|
|
251
|
+
if (!validExisting) {
|
|
252
|
+
const lockError = new Error(
|
|
253
|
+
`The WTAgent session lock at ${filePath} has no verifiable owner.`,
|
|
254
|
+
);
|
|
255
|
+
lockError.code = "SESSION_STATE_LOCKED";
|
|
256
|
+
throw lockError;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (processIsAlive(existingPid)) {
|
|
260
|
+
const startedAt = await processStartTimeMs(existingPid)
|
|
261
|
+
.catch(() => null);
|
|
262
|
+
const lockCreatedAt = Date.parse(existing.createdAt);
|
|
263
|
+
const recycled = startedAt != null
|
|
264
|
+
&& startedAt > lockCreatedAt + 2_000;
|
|
265
|
+
if (!recycled) {
|
|
266
|
+
const confirmed = await readPersistenceLock(filePath);
|
|
267
|
+
if (confirmed?.token !== existing.token) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const confirmed = await readPersistenceLock(filePath);
|
|
276
|
+
if (confirmed?.token !== existing.token) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// The quorum mutex remains held across this atomic replacement, so every
|
|
280
|
+
// current-version stale reaper observes either the old or the new inode.
|
|
281
|
+
// Locks from an older format are safe to migrate once their owner is dead.
|
|
282
|
+
await replaceFileAtomic(candidatePath, filePath);
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const current = await readPersistenceLock(filePath);
|
|
288
|
+
if (current?.token !== token) {
|
|
289
|
+
const error = new Error(
|
|
290
|
+
`Could not acquire the WTAgent session lock at ${filePath}.`,
|
|
291
|
+
);
|
|
292
|
+
error.code = "SESSION_STATE_LOCKED";
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
acquired = true;
|
|
296
|
+
let released = false;
|
|
297
|
+
return async () => {
|
|
298
|
+
if (released) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
released = true;
|
|
302
|
+
try {
|
|
303
|
+
const latest = await readPersistenceLock(filePath);
|
|
304
|
+
if (latest?.token === token) {
|
|
305
|
+
await fs.rm(filePath, { force: true });
|
|
306
|
+
}
|
|
307
|
+
} finally {
|
|
308
|
+
await lockMutex.close();
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
} finally {
|
|
312
|
+
await fs.rm(candidatePath, { force: true }).catch(() => null);
|
|
313
|
+
if (!acquired) {
|
|
314
|
+
await lockMutex.close().catch(() => null);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function withPersistenceLock(directory, callback) {
|
|
320
|
+
const key = path.resolve(directory);
|
|
321
|
+
const previous = persistenceQueues.get(key) ?? Promise.resolve();
|
|
322
|
+
const operation = previous
|
|
323
|
+
.catch(() => null)
|
|
324
|
+
.then(async () => {
|
|
325
|
+
const release = await acquirePersistenceLock(key);
|
|
326
|
+
try {
|
|
327
|
+
return await callback();
|
|
328
|
+
} finally {
|
|
329
|
+
await release();
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
persistenceQueues.set(key, operation);
|
|
333
|
+
void operation.finally(() => {
|
|
334
|
+
if (persistenceQueues.get(key) === operation) {
|
|
335
|
+
persistenceQueues.delete(key);
|
|
336
|
+
}
|
|
337
|
+
}).catch(() => null);
|
|
338
|
+
return operation;
|
|
339
|
+
}
|
|
13
340
|
|
|
14
341
|
async function chmodOwnerOnly(target, mode) {
|
|
15
342
|
if (process.platform !== "win32") {
|
|
@@ -125,6 +452,76 @@ async function appendOwnerOnly(filePath, content) {
|
|
|
125
452
|
}
|
|
126
453
|
}
|
|
127
454
|
|
|
455
|
+
async function truncateOwnerOnly(filePath, length) {
|
|
456
|
+
await assertSafeFile(filePath);
|
|
457
|
+
const handle = await fs.open(
|
|
458
|
+
filePath,
|
|
459
|
+
fsConstants.O_WRONLY | NO_FOLLOW,
|
|
460
|
+
);
|
|
461
|
+
try {
|
|
462
|
+
const stats = await handle.stat();
|
|
463
|
+
if (!stats.isFile()) {
|
|
464
|
+
throw new Error(`Session path is not a regular file: ${filePath}`);
|
|
465
|
+
}
|
|
466
|
+
await handle.truncate(length);
|
|
467
|
+
if (process.platform !== "win32") {
|
|
468
|
+
await handle.chmod(FILE_MODE);
|
|
469
|
+
}
|
|
470
|
+
await handle.sync();
|
|
471
|
+
} finally {
|
|
472
|
+
await handle.close();
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function readJsonlRecords(filePath, {
|
|
477
|
+
allowMissing = false,
|
|
478
|
+
repairTrailing = false,
|
|
479
|
+
} = {}) {
|
|
480
|
+
let bytes;
|
|
481
|
+
try {
|
|
482
|
+
await assertSafeFile(filePath);
|
|
483
|
+
bytes = await fs.readFile(filePath);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (allowMissing && error.code === "ENOENT") {
|
|
486
|
+
return { records: [], needsLeadingNewline: false };
|
|
487
|
+
}
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const raw = bytes.toString("utf8");
|
|
492
|
+
const terminalNewline = bytes.length === 0 || bytes.at(-1) === 0x0a;
|
|
493
|
+
const lines = raw.split(/\r?\n/);
|
|
494
|
+
const records = [];
|
|
495
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
496
|
+
const line = lines[index];
|
|
497
|
+
if (!line.trim()) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
records.push(JSON.parse(line));
|
|
502
|
+
} catch (error) {
|
|
503
|
+
const trailingPartial = !terminalNewline && index === lines.length - 1;
|
|
504
|
+
if (!trailingPartial) {
|
|
505
|
+
throw error;
|
|
506
|
+
}
|
|
507
|
+
if (repairTrailing) {
|
|
508
|
+
const lastNewline = bytes.lastIndexOf(0x0a);
|
|
509
|
+
await truncateOwnerOnly(filePath, lastNewline < 0 ? 0 : lastNewline + 1);
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
records,
|
|
513
|
+
needsLeadingNewline: false,
|
|
514
|
+
trailingPartial: true,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
records,
|
|
520
|
+
needsLeadingNewline: !terminalNewline && raw.trim().length > 0,
|
|
521
|
+
trailingPartial: false,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
128
525
|
function rolloutFileName(createdAt, sessionId) {
|
|
129
526
|
const stamp = createdAt.replaceAll(":", "-");
|
|
130
527
|
return `rollout-${stamp}-${sessionId}.jsonl`;
|
|
@@ -147,12 +544,20 @@ function sessionMetaRecord(state) {
|
|
|
147
544
|
}
|
|
148
545
|
|
|
149
546
|
export class AgentSession {
|
|
150
|
-
constructor({
|
|
547
|
+
constructor({
|
|
548
|
+
sessionsDir,
|
|
549
|
+
directory,
|
|
550
|
+
state,
|
|
551
|
+
stateFileName = "session.json",
|
|
552
|
+
persistedRevision = stateRevision(state),
|
|
553
|
+
}) {
|
|
151
554
|
this.sessionsDir = sessionsDir;
|
|
152
555
|
this.directory = directory;
|
|
153
556
|
this.state = state;
|
|
154
557
|
this.sessionIdentifier = state.sessionId;
|
|
155
558
|
this.stateFileName = stateFileName;
|
|
559
|
+
this.persistedRevision = persistedRevision;
|
|
560
|
+
this.persistenceQueue = Promise.resolve();
|
|
156
561
|
}
|
|
157
562
|
|
|
158
563
|
static async create({ sessionsDir, tasksDir, task, projectRoot, mode, provider = "chatgpt" }) {
|
|
@@ -173,8 +578,13 @@ export class AgentSession {
|
|
|
173
578
|
turn: 0,
|
|
174
579
|
runCount: 0,
|
|
175
580
|
conversationUrl: null,
|
|
581
|
+
conversationTargetId: null,
|
|
582
|
+
lastUserMessageId: null,
|
|
176
583
|
lastAssistantMessageId: null,
|
|
584
|
+
pendingOutbound: null,
|
|
585
|
+
pendingAssistantTurn: null,
|
|
177
586
|
activeMode: null,
|
|
587
|
+
stateRevision: 0,
|
|
178
588
|
createdAt: now,
|
|
179
589
|
updatedAt: now,
|
|
180
590
|
rolloutFile: rolloutFileName(now, sessionId),
|
|
@@ -189,6 +599,7 @@ export class AgentSession {
|
|
|
189
599
|
sessionsDir: root,
|
|
190
600
|
directory,
|
|
191
601
|
state,
|
|
602
|
+
persistedRevision: null,
|
|
192
603
|
});
|
|
193
604
|
await session.save();
|
|
194
605
|
await appendOwnerOnly(
|
|
@@ -241,8 +652,13 @@ export class AgentSession {
|
|
|
241
652
|
: (state.status ?? "idle");
|
|
242
653
|
state.provider ??= "chatgpt";
|
|
243
654
|
state.runCount ??= 0;
|
|
655
|
+
state.conversationTargetId ??= null;
|
|
656
|
+
state.lastUserMessageId ??= null;
|
|
244
657
|
state.lastAssistantMessageId ??= null;
|
|
658
|
+
state.pendingOutbound ??= null;
|
|
659
|
+
state.pendingAssistantTurn ??= null;
|
|
245
660
|
state.activeMode ??= null;
|
|
661
|
+
state.stateRevision = stateRevision(state);
|
|
246
662
|
state.rolloutFile ??= "transcript.jsonl";
|
|
247
663
|
state.lastMessage ??= state.finalMessage ?? null;
|
|
248
664
|
state.completedTools ??= {};
|
|
@@ -254,6 +670,7 @@ export class AgentSession {
|
|
|
254
670
|
directory,
|
|
255
671
|
state,
|
|
256
672
|
stateFileName,
|
|
673
|
+
persistedRevision: state.stateRevision,
|
|
257
674
|
});
|
|
258
675
|
}
|
|
259
676
|
|
|
@@ -312,16 +729,91 @@ export class AgentSession {
|
|
|
312
729
|
}
|
|
313
730
|
}
|
|
314
731
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
732
|
+
#queuePersistenceOperation(callback) {
|
|
733
|
+
const operation = this.persistenceQueue
|
|
734
|
+
.catch(() => null)
|
|
735
|
+
.then(() => withPersistenceLock(this.directory, callback));
|
|
736
|
+
this.persistenceQueue = operation;
|
|
737
|
+
return operation;
|
|
318
738
|
}
|
|
319
739
|
|
|
320
|
-
async
|
|
740
|
+
async #readPersistedStateLocked() {
|
|
741
|
+
const statePath = path.join(this.directory, this.stateFileName);
|
|
742
|
+
try {
|
|
743
|
+
await assertSafeFile(statePath);
|
|
744
|
+
return JSON.parse(await fs.readFile(statePath, "utf8"));
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (error.code === "ENOENT") {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async #assertPersistedRevisionLocked() {
|
|
321
754
|
await this.validateDirectory();
|
|
755
|
+
const persisted = await this.#readPersistedStateLocked();
|
|
756
|
+
const expectedRevision = this.persistedRevision;
|
|
757
|
+
if (expectedRevision == null) {
|
|
758
|
+
if (persisted != null) {
|
|
759
|
+
throw sessionStateConflict(
|
|
760
|
+
"Session state appeared while creating its initial checkpoint.",
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const persistedId = persisted?.sessionId ?? persisted?.taskId ?? null;
|
|
767
|
+
if (
|
|
768
|
+
persisted == null
|
|
769
|
+
|| persistedId !== this.sessionIdentifier
|
|
770
|
+
|| stateRevision(persisted) !== expectedRevision
|
|
771
|
+
) {
|
|
772
|
+
throw sessionStateConflict(
|
|
773
|
+
`Session state changed on disk before revision ${expectedRevision} could be saved.`,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async #saveSnapshotLocked(snapshot) {
|
|
779
|
+
await this.#assertPersistedRevisionLocked();
|
|
780
|
+
const nextRevision = (this.persistedRevision ?? 0) + 1;
|
|
781
|
+
snapshot.stateRevision = nextRevision;
|
|
322
782
|
await writeJsonAtomic(
|
|
323
783
|
path.join(this.directory, this.stateFileName),
|
|
324
|
-
|
|
784
|
+
snapshot,
|
|
785
|
+
);
|
|
786
|
+
this.persistedRevision = nextRevision;
|
|
787
|
+
this.state.stateRevision = nextRevision;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async #mutateState(callback) {
|
|
791
|
+
return await this.#queuePersistenceOperation(async () => {
|
|
792
|
+
await this.#assertPersistedRevisionLocked();
|
|
793
|
+
const nextState = structuredClone(this.state);
|
|
794
|
+
const result = await callback(nextState);
|
|
795
|
+
await this.#saveSnapshotLocked(nextState);
|
|
796
|
+
this.state = nextState;
|
|
797
|
+
return result;
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async update(patch) {
|
|
802
|
+
const requested = structuredClone(patch);
|
|
803
|
+
await this.#mutateState((nextState) => {
|
|
804
|
+
Object.assign(nextState, requested, {
|
|
805
|
+
updatedAt: new Date().toISOString(),
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async save() {
|
|
811
|
+
// Navigation callbacks and Runtime checkpoints can update the same Session
|
|
812
|
+
// concurrently. The instance queue preserves call order; the owner-only
|
|
813
|
+
// interprocess lock plus persisted revision prevents a separately loaded,
|
|
814
|
+
// stale AgentSession from replacing a newer checkpoint on disk.
|
|
815
|
+
return await this.#queuePersistenceOperation(
|
|
816
|
+
() => this.#saveSnapshotLocked(structuredClone(this.state)),
|
|
325
817
|
);
|
|
326
818
|
}
|
|
327
819
|
|
|
@@ -340,6 +832,31 @@ export class AgentSession {
|
|
|
340
832
|
return event;
|
|
341
833
|
}
|
|
342
834
|
|
|
835
|
+
async hasEvent(type) {
|
|
836
|
+
await this.validateDirectory();
|
|
837
|
+
const eventsPath = path.join(this.directory, "events.jsonl");
|
|
838
|
+
let raw;
|
|
839
|
+
try {
|
|
840
|
+
await assertSafeFile(eventsPath);
|
|
841
|
+
raw = await fs.readFile(eventsPath, "utf8");
|
|
842
|
+
} catch (error) {
|
|
843
|
+
if (error.code === "ENOENT") {
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
850
|
+
if (!line.trim()) {
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (JSON.parse(line).type === type) {
|
|
854
|
+
return true;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
|
|
343
860
|
async appendToolOutput(payload) {
|
|
344
861
|
await this.validateDirectory();
|
|
345
862
|
const record = {
|
|
@@ -354,44 +871,127 @@ export class AgentSession {
|
|
|
354
871
|
return record;
|
|
355
872
|
}
|
|
356
873
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
874
|
+
#queueTranscriptOperation(callback) {
|
|
875
|
+
return this.#queuePersistenceOperation(callback);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
#transcriptPath() {
|
|
879
|
+
return path.join(this.directory, this.state.rolloutFile);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
#validateTranscriptIdempotencyKey(idempotencyKey) {
|
|
883
|
+
if (
|
|
884
|
+
typeof idempotencyKey !== "string"
|
|
885
|
+
|| idempotencyKey.length === 0
|
|
886
|
+
|| idempotencyKey.length > 512
|
|
887
|
+
|| /[\0\r\n]/.test(idempotencyKey)
|
|
888
|
+
) {
|
|
889
|
+
throw new Error("A non-empty transcript idempotency key is required.");
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async #appendTranscriptItemLocked(item, { kind = "response_item" } = {}) {
|
|
360
894
|
await this.validateDirectory();
|
|
895
|
+
const transcriptPath = this.#transcriptPath();
|
|
896
|
+
const state = await readJsonlRecords(transcriptPath, {
|
|
897
|
+
allowMissing: true,
|
|
898
|
+
repairTrailing: true,
|
|
899
|
+
});
|
|
361
900
|
const record = {
|
|
362
901
|
timestamp: new Date().toISOString(),
|
|
363
902
|
type: kind,
|
|
364
|
-
payload: item,
|
|
903
|
+
payload: JSON.parse(JSON.stringify(item)),
|
|
365
904
|
};
|
|
366
905
|
await appendOwnerOnly(
|
|
367
|
-
|
|
368
|
-
`${JSON.stringify(record)}\n`,
|
|
906
|
+
transcriptPath,
|
|
907
|
+
`${state.needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)}\n`,
|
|
369
908
|
);
|
|
370
909
|
return record;
|
|
371
910
|
}
|
|
372
911
|
|
|
373
|
-
|
|
374
|
-
|
|
912
|
+
async #appendTranscriptItemOnceLocked(item, {
|
|
913
|
+
idempotencyKey,
|
|
914
|
+
kind = "response_item",
|
|
915
|
+
}) {
|
|
916
|
+
this.#validateTranscriptIdempotencyKey(idempotencyKey);
|
|
375
917
|
await this.validateDirectory();
|
|
376
|
-
const transcriptPath =
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
918
|
+
const transcriptPath = this.#transcriptPath();
|
|
919
|
+
const state = await readJsonlRecords(transcriptPath, {
|
|
920
|
+
allowMissing: true,
|
|
921
|
+
repairTrailing: true,
|
|
922
|
+
});
|
|
923
|
+
const payload = JSON.parse(JSON.stringify(item));
|
|
924
|
+
const matching = state.records.filter((record) => (
|
|
925
|
+
record?.wtagent?.idempotencyKey === idempotencyKey
|
|
926
|
+
));
|
|
927
|
+
if (matching.length > 0) {
|
|
928
|
+
const identical = matching.every((record) => (
|
|
929
|
+
record.type === kind
|
|
930
|
+
&& isDeepStrictEqual(record.payload ?? record.item, payload)
|
|
931
|
+
));
|
|
932
|
+
if (!identical) {
|
|
933
|
+
throw new Error(
|
|
934
|
+
`Transcript idempotency key collision: ${idempotencyKey}`,
|
|
935
|
+
);
|
|
384
936
|
}
|
|
385
|
-
|
|
937
|
+
return matching[0];
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const record = {
|
|
941
|
+
timestamp: new Date().toISOString(),
|
|
942
|
+
type: kind,
|
|
943
|
+
wtagent: { idempotencyKey },
|
|
944
|
+
payload,
|
|
945
|
+
};
|
|
946
|
+
await appendOwnerOnly(
|
|
947
|
+
transcriptPath,
|
|
948
|
+
`${state.needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)}\n`,
|
|
949
|
+
);
|
|
950
|
+
return record;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Appends one canonical transcript item directly to this conversation's
|
|
954
|
+
// Codex rollout JSONL. The XML sent to ChatGPT Web is transport-only.
|
|
955
|
+
async appendTranscriptItem(item, {
|
|
956
|
+
kind = "response_item",
|
|
957
|
+
idempotencyKey = null,
|
|
958
|
+
} = {}) {
|
|
959
|
+
if (idempotencyKey != null) {
|
|
960
|
+
return await this.appendTranscriptItemOnce(item, {
|
|
961
|
+
kind,
|
|
962
|
+
idempotencyKey,
|
|
963
|
+
});
|
|
386
964
|
}
|
|
965
|
+
return await this.#queueTranscriptOperation(
|
|
966
|
+
() => this.#appendTranscriptItemLocked(item, { kind }),
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async appendTranscriptItemOnce(item, {
|
|
971
|
+
idempotencyKey,
|
|
972
|
+
kind = "response_item",
|
|
973
|
+
} = {}) {
|
|
974
|
+
this.#validateTranscriptIdempotencyKey(idempotencyKey);
|
|
975
|
+
return await this.#queueTranscriptOperation(
|
|
976
|
+
() => this.#appendTranscriptItemOnceLocked(item, {
|
|
977
|
+
kind,
|
|
978
|
+
idempotencyKey,
|
|
979
|
+
}),
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async #readTranscriptLocked() {
|
|
984
|
+
await this.validateDirectory();
|
|
985
|
+
const {
|
|
986
|
+
records,
|
|
987
|
+
trailingPartial = false,
|
|
988
|
+
} = await readJsonlRecords(this.#transcriptPath(), {
|
|
989
|
+
allowMissing: true,
|
|
990
|
+
});
|
|
387
991
|
|
|
388
992
|
const items = [];
|
|
389
993
|
let storedMeta = null;
|
|
390
|
-
for (const
|
|
391
|
-
if (!line.trim()) {
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
const record = JSON.parse(line);
|
|
994
|
+
for (const record of records) {
|
|
395
995
|
if (record.type === "session_meta") {
|
|
396
996
|
storedMeta = record.payload ?? null;
|
|
397
997
|
continue;
|
|
@@ -400,10 +1000,32 @@ export class AgentSession {
|
|
|
400
1000
|
items.push({
|
|
401
1001
|
timestamp: record.timestamp,
|
|
402
1002
|
item: record.payload ?? record.item,
|
|
1003
|
+
...(record.wtagent?.idempotencyKey
|
|
1004
|
+
? { idempotencyKey: record.wtagent.idempotencyKey }
|
|
1005
|
+
: {}),
|
|
403
1006
|
});
|
|
404
1007
|
}
|
|
405
1008
|
}
|
|
406
|
-
return {
|
|
1009
|
+
return {
|
|
1010
|
+
meta: this.#transcriptMeta(storedMeta),
|
|
1011
|
+
items,
|
|
1012
|
+
trailingPartial,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Reads the conversation rollout as { meta, items } for portable exporters.
|
|
1017
|
+
async readTranscript({ allowTrailingPartial = false } = {}) {
|
|
1018
|
+
const transcript = await this.#queueTranscriptOperation(
|
|
1019
|
+
() => this.#readTranscriptLocked(),
|
|
1020
|
+
);
|
|
1021
|
+
if (transcript.trailingPartial && !allowTrailingPartial) {
|
|
1022
|
+
const error = new Error(
|
|
1023
|
+
"The canonical transcript ends with an incomplete JSONL record from an interrupted write.",
|
|
1024
|
+
);
|
|
1025
|
+
error.code = "TRANSCRIPT_INCOMPLETE";
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
1028
|
+
return transcript;
|
|
407
1029
|
}
|
|
408
1030
|
|
|
409
1031
|
#transcriptMeta(storedMeta = null) {
|
|
@@ -419,6 +1041,230 @@ export class AgentSession {
|
|
|
419
1041
|
};
|
|
420
1042
|
}
|
|
421
1043
|
|
|
1044
|
+
async commitPendingOutboundHandoff({ outboundId, handoff = {} }) {
|
|
1045
|
+
if (typeof outboundId !== "string" || outboundId.length === 0) {
|
|
1046
|
+
throw new Error("A pending outbound ID is required for assistant handoff.");
|
|
1047
|
+
}
|
|
1048
|
+
return await this.#queuePersistenceOperation(async () => {
|
|
1049
|
+
// Hold one interprocess lock from the persisted-revision check through all
|
|
1050
|
+
// transcript appends and the state swap. A competing writer can neither
|
|
1051
|
+
// supersede the outbound between those steps nor inherit transcript items
|
|
1052
|
+
// from a handoff whose compare-and-swap failed.
|
|
1053
|
+
await this.#assertPersistedRevisionLocked();
|
|
1054
|
+
const existingHandoff = this.state.pendingAssistantTurn;
|
|
1055
|
+
if (
|
|
1056
|
+
this.state.pendingOutbound?.outboundId !== outboundId
|
|
1057
|
+
&& existingHandoff?.sourceOutboundId === outboundId
|
|
1058
|
+
) {
|
|
1059
|
+
return structuredClone(existingHandoff);
|
|
1060
|
+
}
|
|
1061
|
+
if (this.state.pendingOutbound?.outboundId !== outboundId) {
|
|
1062
|
+
throw new Error(
|
|
1063
|
+
`Pending outbound changed before assistant handoff: ${outboundId}`,
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const transcriptItems = Array.isArray(
|
|
1068
|
+
this.state.pendingOutbound.transcriptItems,
|
|
1069
|
+
)
|
|
1070
|
+
? structuredClone(this.state.pendingOutbound.transcriptItems)
|
|
1071
|
+
: [];
|
|
1072
|
+
for (let index = 0; index < transcriptItems.length; index += 1) {
|
|
1073
|
+
const entry = transcriptItems[index];
|
|
1074
|
+
const item = entry?.item ?? entry;
|
|
1075
|
+
const kind = entry?.item
|
|
1076
|
+
? (entry.kind ?? "response_item")
|
|
1077
|
+
: "response_item";
|
|
1078
|
+
await this.#appendTranscriptItemOnceLocked(item, {
|
|
1079
|
+
kind,
|
|
1080
|
+
idempotencyKey: `outbound:${outboundId}:transcript:${index}`,
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const currentOutbound = this.state.pendingOutbound;
|
|
1085
|
+
if (currentOutbound?.outboundId !== outboundId) {
|
|
1086
|
+
throw new Error(
|
|
1087
|
+
`Pending outbound changed while committing assistant handoff: ${outboundId}`,
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
const now = new Date().toISOString();
|
|
1091
|
+
const requested = structuredClone(handoff);
|
|
1092
|
+
const pendingAssistantTurn = {
|
|
1093
|
+
...requested,
|
|
1094
|
+
version: 1,
|
|
1095
|
+
handoffId: `outbound:${outboundId}`,
|
|
1096
|
+
sourceOutboundId: outboundId,
|
|
1097
|
+
outboundKind: currentOutbound.kind ?? "runtime_message",
|
|
1098
|
+
runtimeTurn: Number.isSafeInteger(requested.runtimeTurn)
|
|
1099
|
+
? requested.runtimeTurn
|
|
1100
|
+
: this.state.turn,
|
|
1101
|
+
status: "waiting",
|
|
1102
|
+
conversationUrl: requested.conversationUrl
|
|
1103
|
+
?? this.state.conversationUrl
|
|
1104
|
+
?? null,
|
|
1105
|
+
conversationTargetId: requested.conversationTargetId
|
|
1106
|
+
?? this.state.conversationTargetId
|
|
1107
|
+
?? null,
|
|
1108
|
+
userMessageId: requested.userMessageId ?? null,
|
|
1109
|
+
userTurn: requested.userTurn ?? null,
|
|
1110
|
+
assistantBaseline: requested.assistantBaseline ?? null,
|
|
1111
|
+
pendingToolAcknowledgement:
|
|
1112
|
+
requested.pendingToolAcknowledgement ?? null,
|
|
1113
|
+
createdAt: requested.createdAt ?? now,
|
|
1114
|
+
updatedAt: now,
|
|
1115
|
+
};
|
|
1116
|
+
const nextState = {
|
|
1117
|
+
...structuredClone(this.state),
|
|
1118
|
+
pendingOutbound: null,
|
|
1119
|
+
pendingAssistantTurn,
|
|
1120
|
+
conversationUrl: pendingAssistantTurn.conversationUrl,
|
|
1121
|
+
conversationTargetId: pendingAssistantTurn.conversationTargetId,
|
|
1122
|
+
...(pendingAssistantTurn.userMessageId
|
|
1123
|
+
? { lastUserMessageId: pendingAssistantTurn.userMessageId }
|
|
1124
|
+
: {}),
|
|
1125
|
+
updatedAt: now,
|
|
1126
|
+
};
|
|
1127
|
+
await this.#saveSnapshotLocked(nextState);
|
|
1128
|
+
this.state = nextState;
|
|
1129
|
+
return structuredClone(pendingAssistantTurn);
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
async refreshPendingAssistantTurn(handoffId, patch = {}) {
|
|
1134
|
+
const requested = structuredClone(patch);
|
|
1135
|
+
const protectedKeys = new Set([
|
|
1136
|
+
"version",
|
|
1137
|
+
"handoffId",
|
|
1138
|
+
"sourceOutboundId",
|
|
1139
|
+
"outboundKind",
|
|
1140
|
+
"runtimeTurn",
|
|
1141
|
+
"status",
|
|
1142
|
+
"createdAt",
|
|
1143
|
+
"rawResponse",
|
|
1144
|
+
"responseHash",
|
|
1145
|
+
]);
|
|
1146
|
+
if (Object.keys(requested).some((key) => protectedKeys.has(key))) {
|
|
1147
|
+
throw new Error("Assistant handoff refresh contains protected fields.");
|
|
1148
|
+
}
|
|
1149
|
+
return await this.#mutateState((nextState) => {
|
|
1150
|
+
const current = nextState.pendingAssistantTurn;
|
|
1151
|
+
if (!current || current.handoffId !== handoffId) {
|
|
1152
|
+
throw new Error(
|
|
1153
|
+
`Pending assistant handoff changed before refresh: ${handoffId}`,
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
if (!["waiting", "complete"].includes(current.status)) {
|
|
1157
|
+
throw new Error(
|
|
1158
|
+
`Invalid pending assistant handoff status: ${String(current.status)}`,
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
const now = new Date().toISOString();
|
|
1162
|
+
const refreshed = {
|
|
1163
|
+
...current,
|
|
1164
|
+
...requested,
|
|
1165
|
+
updatedAt: now,
|
|
1166
|
+
};
|
|
1167
|
+
nextState.pendingAssistantTurn = refreshed;
|
|
1168
|
+
if (refreshed.conversationUrl) {
|
|
1169
|
+
nextState.conversationUrl = refreshed.conversationUrl;
|
|
1170
|
+
}
|
|
1171
|
+
if (refreshed.conversationTargetId) {
|
|
1172
|
+
nextState.conversationTargetId = refreshed.conversationTargetId;
|
|
1173
|
+
}
|
|
1174
|
+
if (refreshed.userMessageId) {
|
|
1175
|
+
nextState.lastUserMessageId = refreshed.userMessageId;
|
|
1176
|
+
}
|
|
1177
|
+
nextState.updatedAt = now;
|
|
1178
|
+
return structuredClone(refreshed);
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
async completePendingAssistantTurn({
|
|
1183
|
+
handoffId,
|
|
1184
|
+
assistantMessageId = null,
|
|
1185
|
+
assistantTurn = null,
|
|
1186
|
+
rawResponse,
|
|
1187
|
+
completedAt = new Date().toISOString(),
|
|
1188
|
+
}) {
|
|
1189
|
+
if (typeof rawResponse !== "string") {
|
|
1190
|
+
throw new Error("A raw assistant response is required for completion.");
|
|
1191
|
+
}
|
|
1192
|
+
const responseHash = createHash("sha256")
|
|
1193
|
+
.update(rawResponse)
|
|
1194
|
+
.digest("hex");
|
|
1195
|
+
const completion = {
|
|
1196
|
+
assistantMessageId,
|
|
1197
|
+
assistantTurn,
|
|
1198
|
+
rawResponse,
|
|
1199
|
+
responseHash,
|
|
1200
|
+
};
|
|
1201
|
+
return await this.#mutateState((nextState) => {
|
|
1202
|
+
const current = nextState.pendingAssistantTurn;
|
|
1203
|
+
if (!current || current.handoffId !== handoffId) {
|
|
1204
|
+
throw new Error(
|
|
1205
|
+
`Pending assistant handoff changed before completion: ${handoffId}`,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
if (current.status === "complete") {
|
|
1209
|
+
const existing = {
|
|
1210
|
+
assistantMessageId: current.assistantMessageId ?? null,
|
|
1211
|
+
assistantTurn: current.assistantTurn ?? null,
|
|
1212
|
+
rawResponse: current.rawResponse,
|
|
1213
|
+
responseHash: current.responseHash,
|
|
1214
|
+
};
|
|
1215
|
+
if (!isDeepStrictEqual(existing, completion)) {
|
|
1216
|
+
throw new Error(
|
|
1217
|
+
`Assistant handoff completion collision: ${handoffId}`,
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
return structuredClone(current);
|
|
1221
|
+
}
|
|
1222
|
+
if (current.status !== "waiting") {
|
|
1223
|
+
throw new Error(
|
|
1224
|
+
`Invalid pending assistant handoff status: ${String(current.status)}`,
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const completed = {
|
|
1229
|
+
...current,
|
|
1230
|
+
...completion,
|
|
1231
|
+
status: "complete",
|
|
1232
|
+
completedAt,
|
|
1233
|
+
updatedAt: completedAt,
|
|
1234
|
+
};
|
|
1235
|
+
nextState.pendingAssistantTurn = completed;
|
|
1236
|
+
// Null is authoritative: retaining an older ID could falsely correlate stale
|
|
1237
|
+
// history after a provider completed a reply without exposing identity.
|
|
1238
|
+
nextState.lastAssistantMessageId = assistantMessageId;
|
|
1239
|
+
nextState.updatedAt = completedAt;
|
|
1240
|
+
return structuredClone(completed);
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async clearPendingAssistantTurn(handoffId, patch = {}) {
|
|
1245
|
+
const requested = structuredClone(patch);
|
|
1246
|
+
const protectedKeys = new Set([
|
|
1247
|
+
"pendingAssistantTurn",
|
|
1248
|
+
"pendingOutbound",
|
|
1249
|
+
"sessionId",
|
|
1250
|
+
]);
|
|
1251
|
+
if (Object.keys(requested).some((key) => protectedKeys.has(key))) {
|
|
1252
|
+
throw new Error("Assistant handoff terminal patch contains protected state.");
|
|
1253
|
+
}
|
|
1254
|
+
await this.#mutateState((nextState) => {
|
|
1255
|
+
const current = nextState.pendingAssistantTurn;
|
|
1256
|
+
if (!current || current.handoffId !== handoffId) {
|
|
1257
|
+
throw new Error(
|
|
1258
|
+
`Pending assistant handoff changed before clearing: ${handoffId}`,
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
Object.assign(nextState, requested, {
|
|
1262
|
+
pendingAssistantTurn: null,
|
|
1263
|
+
updatedAt: new Date().toISOString(),
|
|
1264
|
+
});
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
422
1268
|
getToolResult(fingerprint) {
|
|
423
1269
|
return this.state.completedTools[fingerprint]?.result ?? null;
|
|
424
1270
|
}
|
|
@@ -428,79 +1274,87 @@ export class AgentSession {
|
|
|
428
1274
|
}
|
|
429
1275
|
|
|
430
1276
|
async claimSideEffectTool(identity) {
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
1277
|
+
const requested = structuredClone(identity);
|
|
1278
|
+
await this.#mutateState((nextState) => {
|
|
1279
|
+
const existing = nextState.sideEffectTools[requested.operationKey];
|
|
1280
|
+
if (existing) {
|
|
1281
|
+
throw new Error(
|
|
1282
|
+
`Tool operation is already claimed: ${requested.operationKey}`,
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
437
1285
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
1286
|
+
const claimedAt = new Date().toISOString();
|
|
1287
|
+
nextState.sideEffectTools[requested.operationKey] = {
|
|
1288
|
+
...requested,
|
|
1289
|
+
status: "running",
|
|
1290
|
+
claimedAt,
|
|
1291
|
+
updatedAt: claimedAt,
|
|
1292
|
+
result: null,
|
|
1293
|
+
};
|
|
1294
|
+
});
|
|
447
1295
|
return await this.appendEvent("tool.claimed", {
|
|
448
|
-
identity,
|
|
1296
|
+
identity: requested,
|
|
449
1297
|
});
|
|
450
1298
|
}
|
|
451
1299
|
|
|
452
1300
|
async recordToolResult(fingerprint, result, { identity = null } = {}) {
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if (identity) {
|
|
460
|
-
const existing = this.getSideEffectTool(identity.operationKey);
|
|
461
|
-
if (!existing || existing.fingerprint !== identity.fingerprint) {
|
|
462
|
-
throw new Error(
|
|
463
|
-
`Tool operation changed before completion: ${identity.operationKey}`,
|
|
464
|
-
);
|
|
465
|
-
}
|
|
466
|
-
this.state.sideEffectTools[identity.operationKey] = {
|
|
467
|
-
...existing,
|
|
468
|
-
status: "completed",
|
|
1301
|
+
const storedResult = structuredClone(result);
|
|
1302
|
+
const storedIdentity = identity ? structuredClone(identity) : null;
|
|
1303
|
+
await this.#mutateState((nextState) => {
|
|
1304
|
+
const completedAt = new Date().toISOString();
|
|
1305
|
+
nextState.completedTools[fingerprint] = {
|
|
469
1306
|
completedAt,
|
|
470
|
-
|
|
471
|
-
result,
|
|
1307
|
+
result: storedResult,
|
|
472
1308
|
};
|
|
473
|
-
}
|
|
474
1309
|
|
|
475
|
-
|
|
476
|
-
|
|
1310
|
+
if (storedIdentity) {
|
|
1311
|
+
const existing = nextState.sideEffectTools[storedIdentity.operationKey];
|
|
1312
|
+
if (!existing || existing.fingerprint !== storedIdentity.fingerprint) {
|
|
1313
|
+
throw new Error(
|
|
1314
|
+
`Tool operation changed before completion: ${storedIdentity.operationKey}`,
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
nextState.sideEffectTools[storedIdentity.operationKey] = {
|
|
1318
|
+
...existing,
|
|
1319
|
+
status: "completed",
|
|
1320
|
+
completedAt,
|
|
1321
|
+
updatedAt: completedAt,
|
|
1322
|
+
result: storedResult,
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
nextState.pendingToolResult = storedResult;
|
|
1327
|
+
});
|
|
477
1328
|
return await this.appendEvent("tool.completed", {
|
|
478
1329
|
fingerprint,
|
|
479
|
-
result,
|
|
1330
|
+
result: storedResult,
|
|
480
1331
|
});
|
|
481
1332
|
}
|
|
482
1333
|
|
|
483
1334
|
async markSideEffectToolUnknown(identity, result) {
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
)
|
|
489
|
-
|
|
1335
|
+
const storedIdentity = structuredClone(identity);
|
|
1336
|
+
const storedResult = structuredClone(result);
|
|
1337
|
+
await this.#mutateState((nextState) => {
|
|
1338
|
+
const existing = nextState.sideEffectTools[storedIdentity.operationKey];
|
|
1339
|
+
if (!existing || existing.fingerprint !== storedIdentity.fingerprint) {
|
|
1340
|
+
throw new Error(
|
|
1341
|
+
`Tool operation changed before unknown completion: ${storedIdentity.operationKey}`,
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
490
1344
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
1345
|
+
const unknownAt = new Date().toISOString();
|
|
1346
|
+
nextState.sideEffectTools[storedIdentity.operationKey] = {
|
|
1347
|
+
...existing,
|
|
1348
|
+
status: "unknown",
|
|
1349
|
+
unknownAt,
|
|
1350
|
+
updatedAt: unknownAt,
|
|
1351
|
+
result: storedResult,
|
|
1352
|
+
};
|
|
1353
|
+
nextState.pendingToolResult = storedResult;
|
|
1354
|
+
});
|
|
501
1355
|
return await this.appendEvent("tool.completion_unknown", {
|
|
502
|
-
identity,
|
|
503
|
-
result,
|
|
1356
|
+
identity: storedIdentity,
|
|
1357
|
+
result: storedResult,
|
|
504
1358
|
});
|
|
505
1359
|
}
|
|
506
1360
|
|
|
@@ -509,9 +1363,6 @@ export class AgentSession {
|
|
|
509
1363
|
}
|
|
510
1364
|
|
|
511
1365
|
async clearPendingToolResult() {
|
|
512
|
-
if (this.state.pendingToolResult == null) {
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
1366
|
await this.update({ pendingToolResult: null });
|
|
516
1367
|
}
|
|
517
1368
|
|
|
@@ -526,42 +1377,46 @@ export class AgentSession {
|
|
|
526
1377
|
path: file.path ?? null,
|
|
527
1378
|
}));
|
|
528
1379
|
}
|
|
529
|
-
this
|
|
530
|
-
|
|
531
|
-
|
|
1380
|
+
await this.#mutateState((nextState) => {
|
|
1381
|
+
nextState.followUps.push(structuredClone(item));
|
|
1382
|
+
nextState.lastError = null;
|
|
1383
|
+
});
|
|
532
1384
|
await this.appendEvent("session.instruction_added", item);
|
|
533
1385
|
}
|
|
534
1386
|
|
|
535
1387
|
async recoverInterruptedSideEffects() {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1388
|
+
await this.#mutateState((nextState) => {
|
|
1389
|
+
for (const [operationKey, entry] of Object.entries(
|
|
1390
|
+
nextState.sideEffectTools,
|
|
1391
|
+
)) {
|
|
1392
|
+
if (entry.status !== "running") {
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
const result = {
|
|
1396
|
+
callId: entry.callId,
|
|
1397
|
+
name: entry.name,
|
|
1398
|
+
operationSignature: entry.requestSignature,
|
|
1399
|
+
ok: false,
|
|
1400
|
+
message:
|
|
1401
|
+
"This tool operation may have started before the local process was interrupted. "
|
|
1402
|
+
+ "Its completion is unknown, so it will not be replayed automatically. "
|
|
1403
|
+
+ "Inspect local state before issuing a deliberate follow-up operation.",
|
|
1404
|
+
meta: {
|
|
1405
|
+
completionUnknown: true,
|
|
1406
|
+
recoverable: true,
|
|
1407
|
+
},
|
|
1408
|
+
};
|
|
1409
|
+
const now = new Date().toISOString();
|
|
1410
|
+
nextState.sideEffectTools[operationKey] = {
|
|
1411
|
+
...entry,
|
|
1412
|
+
status: "unknown",
|
|
1413
|
+
unknownAt: now,
|
|
1414
|
+
updatedAt: now,
|
|
1415
|
+
result,
|
|
1416
|
+
};
|
|
1417
|
+
nextState.pendingToolResult ??= result;
|
|
539
1418
|
}
|
|
540
|
-
|
|
541
|
-
callId: entry.callId,
|
|
542
|
-
name: entry.name,
|
|
543
|
-
operationSignature: entry.requestSignature,
|
|
544
|
-
ok: false,
|
|
545
|
-
message:
|
|
546
|
-
"This tool operation may have started before the local process was interrupted. "
|
|
547
|
-
+ "Its completion is unknown, so it will not be replayed automatically. "
|
|
548
|
-
+ "Inspect local state before issuing a deliberate follow-up operation.",
|
|
549
|
-
meta: {
|
|
550
|
-
completionUnknown: true,
|
|
551
|
-
recoverable: true,
|
|
552
|
-
},
|
|
553
|
-
};
|
|
554
|
-
const now = new Date().toISOString();
|
|
555
|
-
this.state.sideEffectTools[operationKey] = {
|
|
556
|
-
...entry,
|
|
557
|
-
status: "unknown",
|
|
558
|
-
unknownAt: now,
|
|
559
|
-
updatedAt: now,
|
|
560
|
-
result,
|
|
561
|
-
};
|
|
562
|
-
this.state.pendingToolResult ??= result;
|
|
563
|
-
}
|
|
564
|
-
await this.save();
|
|
1419
|
+
});
|
|
565
1420
|
}
|
|
566
1421
|
}
|
|
567
1422
|
|