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,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
cdata,
|
|
4
4
|
extractTrailingProse,
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
serializeToolResult,
|
|
8
8
|
stripUiNoiseLines,
|
|
9
9
|
} from "../protocol/xml-protocol.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
appendSystemReminder,
|
|
12
|
+
DEFAULT_SYSTEM_REMINDER,
|
|
13
|
+
} from "../protocol/markers.js";
|
|
11
14
|
import {
|
|
12
15
|
buildBootstrapPrompt,
|
|
13
16
|
buildResumePrompt,
|
|
@@ -16,6 +19,7 @@ import {
|
|
|
16
19
|
assistantMessage,
|
|
17
20
|
functionCall,
|
|
18
21
|
functionCallOutput,
|
|
22
|
+
messageText,
|
|
19
23
|
toolResultOutput,
|
|
20
24
|
userMessage,
|
|
21
25
|
} from "../session/canonical-transcript.js";
|
|
@@ -26,7 +30,10 @@ import {
|
|
|
26
30
|
ProtocolError,
|
|
27
31
|
ToolValidationError,
|
|
28
32
|
} from "../shared/errors.js";
|
|
29
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
isConnectionLostError,
|
|
35
|
+
isValidOutboundCorrelationId,
|
|
36
|
+
} from "../browser/base-web-adapter.js";
|
|
30
37
|
import { isUsageLimitNotice } from "../shared/usage-limit.js";
|
|
31
38
|
|
|
32
39
|
const EMPTY_ASSISTANT_CONTINUE_MESSAGE =
|
|
@@ -44,6 +51,11 @@ const GENERATION_FAILED_CONTINUE_MESSAGE =
|
|
|
44
51
|
+ "Retry the immediately preceding task from the existing conversation context. Do not "
|
|
45
52
|
+ "repeat any local tool operation whose result is already present. Reply using the "
|
|
46
53
|
+ "required <agent_response> XML protocol.";
|
|
54
|
+
const OUTBOUND_CORRELATION_PREFIX =
|
|
55
|
+
" Opaque WTAgent transport correlation ID (do not repeat): ";
|
|
56
|
+
const OUTBOUND_CORRELATION_RESERVE_BYTES = utf8ByteLength(
|
|
57
|
+
`${OUTBOUND_CORRELATION_PREFIX}${"0".repeat(36)}.`,
|
|
58
|
+
);
|
|
47
59
|
|
|
48
60
|
function canonicalize(value) {
|
|
49
61
|
if (Array.isArray(value)) {
|
|
@@ -155,6 +167,11 @@ export class AgentRuntime {
|
|
|
155
167
|
this.onEvent = onEvent;
|
|
156
168
|
this.postAuthSetup = postAuthSetup;
|
|
157
169
|
this.limits = limits;
|
|
170
|
+
this.conversationIdentityQueue = Promise.resolve();
|
|
171
|
+
this.deferConversationIdentityPersistence = false;
|
|
172
|
+
this.adapter.setConversationIdentityListener?.((identity) =>
|
|
173
|
+
this.#queueConversationIdentity(identity)
|
|
174
|
+
);
|
|
158
175
|
}
|
|
159
176
|
|
|
160
177
|
async emit(type, payload = {}) {
|
|
@@ -163,60 +180,409 @@ export class AgentRuntime {
|
|
|
163
180
|
return event;
|
|
164
181
|
}
|
|
165
182
|
|
|
166
|
-
async sendMessage(text, {
|
|
167
|
-
|
|
183
|
+
async sendMessage(text, {
|
|
184
|
+
files = [],
|
|
185
|
+
maxBytes = null,
|
|
186
|
+
allowFreshReconnect = false,
|
|
187
|
+
allowAssistantContinuation = false,
|
|
188
|
+
outboundKind = "runtime_message",
|
|
189
|
+
transcriptItems = [],
|
|
190
|
+
runtimeTurn = null,
|
|
191
|
+
pendingToolAcknowledgement = null,
|
|
192
|
+
} = {}) {
|
|
193
|
+
const outboundId = randomUUID();
|
|
194
|
+
const message = appendSystemReminder(
|
|
195
|
+
text,
|
|
196
|
+
`${DEFAULT_SYSTEM_REMINDER}${OUTBOUND_CORRELATION_PREFIX}${outboundId}.`,
|
|
197
|
+
);
|
|
198
|
+
const pendingOutbound = {
|
|
199
|
+
kind: outboundKind,
|
|
200
|
+
outboundId,
|
|
201
|
+
messageHash: createHash("sha256").update(message).digest("hex"),
|
|
202
|
+
preparedAt: new Date().toISOString(),
|
|
203
|
+
transcriptItems,
|
|
204
|
+
runtimeTurn: Number.isSafeInteger(runtimeTurn) ? runtimeTurn : null,
|
|
205
|
+
pendingToolAcknowledgement,
|
|
206
|
+
conversationUrl: this.session.state.conversationUrl ?? null,
|
|
207
|
+
conversationTargetId: this.session.state.conversationTargetId ?? null,
|
|
208
|
+
};
|
|
209
|
+
await this.#queueSessionUpdate({ pendingOutbound });
|
|
168
210
|
try {
|
|
169
|
-
await this.#sendMessageWithReconnect(message, {
|
|
211
|
+
const sendResult = await this.#sendMessageWithReconnect(message, {
|
|
212
|
+
files,
|
|
213
|
+
maxBytes,
|
|
214
|
+
allowFreshReconnect,
|
|
215
|
+
allowAssistantContinuation,
|
|
216
|
+
outboundId,
|
|
217
|
+
});
|
|
218
|
+
const handoff = await this.#queueSessionOperation(() => (
|
|
219
|
+
this.session.commitPendingOutboundHandoff({
|
|
220
|
+
outboundId,
|
|
221
|
+
handoff: {
|
|
222
|
+
runtimeTurn: pendingOutbound.runtimeTurn
|
|
223
|
+
?? this.session.state.turn,
|
|
224
|
+
conversationUrl: sendResult?.conversationUrl
|
|
225
|
+
?? this.session.state.conversationUrl,
|
|
226
|
+
conversationTargetId: sendResult?.conversationTargetId
|
|
227
|
+
?? this.session.state.conversationTargetId,
|
|
228
|
+
userMessageId: sendResult?.userMessageId ?? null,
|
|
229
|
+
userTurn: sendResult?.userTurn ?? null,
|
|
230
|
+
assistantBaseline: sendResult?.assistantBaseline ?? null,
|
|
231
|
+
preOutboundMarkerIds: sendResult?.preOutboundMarkerIds ?? [],
|
|
232
|
+
pendingToolAcknowledgement,
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
));
|
|
236
|
+
return { ...sendResult, pendingAssistantTurn: handoff };
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const sendStatus = this.adapter.getLastSendStatus?.() ?? "commit-unknown";
|
|
239
|
+
const nextPending = sendStatus === "not-submitted"
|
|
240
|
+
? null
|
|
241
|
+
: {
|
|
242
|
+
...pendingOutbound,
|
|
243
|
+
status: "commit-unknown",
|
|
244
|
+
failedAt: new Date().toISOString(),
|
|
245
|
+
};
|
|
246
|
+
await this.#queueSessionOperation(async () => {
|
|
247
|
+
if (this.session.state.pendingOutbound?.outboundId === outboundId) {
|
|
248
|
+
await this.session.update({ pendingOutbound: nextPending });
|
|
249
|
+
}
|
|
250
|
+
}).catch(() => {});
|
|
251
|
+
throw error;
|
|
170
252
|
} finally {
|
|
171
|
-
|
|
253
|
+
// URL observation must never replace the send result. Top-frame navigation
|
|
254
|
+
// is persisted eagerly; this is only a best-effort final checkpoint.
|
|
255
|
+
const identity = await this.#syncConversationIdentity({
|
|
256
|
+
suppressErrors: true,
|
|
257
|
+
});
|
|
258
|
+
await this.#refreshPendingOutboundIdentity(outboundId, identity)
|
|
172
259
|
.catch(() => null);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async #persistConversationIdentity(identity, { allowFresh = false } = {}) {
|
|
264
|
+
const conversationUrl = identity?.conversationUrl ?? null;
|
|
265
|
+
const kind = identity?.kind
|
|
266
|
+
?? (conversationUrl && this.adapter.classifyConversationUrl?.(conversationUrl));
|
|
267
|
+
if (
|
|
268
|
+
!conversationUrl
|
|
269
|
+
|| (
|
|
270
|
+
!["restorable", "provisional"].includes(kind)
|
|
271
|
+
&& !(allowFresh && kind === "fresh")
|
|
272
|
+
)
|
|
273
|
+
) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const targetId = typeof identity?.targetId === "string"
|
|
278
|
+
&& identity.targetId
|
|
279
|
+
? identity.targetId
|
|
280
|
+
: null;
|
|
281
|
+
const patch = {};
|
|
282
|
+
if (conversationUrl !== this.session.state.conversationUrl) {
|
|
283
|
+
patch.conversationUrl = conversationUrl;
|
|
284
|
+
}
|
|
285
|
+
if (
|
|
286
|
+
targetId
|
|
287
|
+
&& targetId !== this.session.state.conversationTargetId
|
|
288
|
+
) {
|
|
289
|
+
patch.conversationTargetId = targetId;
|
|
290
|
+
}
|
|
291
|
+
if (Object.keys(patch).length > 0) {
|
|
292
|
+
await this.session.update(patch);
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
conversationUrl,
|
|
296
|
+
targetId: targetId ?? this.session.state.conversationTargetId ?? null,
|
|
297
|
+
kind,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#queueConversationIdentity(identity, options = {}) {
|
|
302
|
+
if (this.deferConversationIdentityPersistence) {
|
|
303
|
+
return Promise.resolve(null);
|
|
304
|
+
}
|
|
305
|
+
const operation = this.conversationIdentityQueue
|
|
306
|
+
.catch(() => null)
|
|
307
|
+
.then(() => this.#persistConversationIdentity(identity, options));
|
|
308
|
+
this.conversationIdentityQueue = operation;
|
|
309
|
+
return operation;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#queueSessionOperation(callback) {
|
|
313
|
+
const operation = this.conversationIdentityQueue
|
|
314
|
+
.catch(() => null)
|
|
315
|
+
.then(callback);
|
|
316
|
+
this.conversationIdentityQueue = operation;
|
|
317
|
+
return operation;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#queueSessionUpdate(patch) {
|
|
321
|
+
return this.#queueSessionOperation(() => this.session.update(patch));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async #refreshPendingOutboundIdentity(
|
|
325
|
+
outboundId,
|
|
326
|
+
identity = null,
|
|
327
|
+
{ required = false } = {},
|
|
328
|
+
) {
|
|
329
|
+
return await this.#queueSessionOperation(async () => {
|
|
330
|
+
const current = this.session.state.pendingOutbound;
|
|
331
|
+
if (!current || current.outboundId !== outboundId) {
|
|
332
|
+
if (required) {
|
|
333
|
+
throw new BrowserAdapterError(
|
|
334
|
+
"The pending outbound changed before its reconnect target could be checkpointed.",
|
|
335
|
+
{ code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
const conversationUrl = identity?.conversationUrl
|
|
341
|
+
?? this.session.state.conversationUrl
|
|
342
|
+
?? current.conversationUrl
|
|
343
|
+
?? null;
|
|
344
|
+
const conversationTargetId = identity?.targetId
|
|
345
|
+
?? identity?.conversationTargetId
|
|
346
|
+
?? this.session.state.conversationTargetId
|
|
347
|
+
?? current.conversationTargetId
|
|
348
|
+
?? null;
|
|
173
349
|
if (
|
|
174
|
-
conversationUrl
|
|
175
|
-
&&
|
|
350
|
+
conversationUrl === current.conversationUrl
|
|
351
|
+
&& conversationTargetId === current.conversationTargetId
|
|
176
352
|
) {
|
|
177
|
-
|
|
353
|
+
return structuredClone(current);
|
|
178
354
|
}
|
|
355
|
+
const refreshed = {
|
|
356
|
+
...current,
|
|
357
|
+
conversationUrl,
|
|
358
|
+
conversationTargetId,
|
|
359
|
+
};
|
|
360
|
+
await this.session.update({
|
|
361
|
+
pendingOutbound: refreshed,
|
|
362
|
+
conversationUrl,
|
|
363
|
+
conversationTargetId,
|
|
364
|
+
});
|
|
365
|
+
return structuredClone(refreshed);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async #syncConversationIdentity({
|
|
370
|
+
suppressErrors = false,
|
|
371
|
+
allowFresh = false,
|
|
372
|
+
identity = null,
|
|
373
|
+
} = {}) {
|
|
374
|
+
// Observe and persist inside one queue entry. If a navigation callback fires
|
|
375
|
+
// while observation awaits the adapter, it queues after this operation and
|
|
376
|
+
// remains authoritative instead of being overwritten by a stale snapshot.
|
|
377
|
+
const operation = this.conversationIdentityQueue
|
|
378
|
+
.catch(() => null)
|
|
379
|
+
.then(async () => {
|
|
380
|
+
let observed = identity;
|
|
381
|
+
if (!observed) {
|
|
382
|
+
if (typeof this.adapter.getConversationIdentity === "function") {
|
|
383
|
+
observed = await this.adapter.getConversationIdentity();
|
|
384
|
+
} else {
|
|
385
|
+
const conversationUrl = await this.adapter.getConversationUrl();
|
|
386
|
+
observed = {
|
|
387
|
+
conversationUrl,
|
|
388
|
+
targetId: null,
|
|
389
|
+
kind: this.adapter.classifyConversationUrl?.(conversationUrl)
|
|
390
|
+
?? (/\/c\//.test(new URL(conversationUrl).pathname)
|
|
391
|
+
? "restorable"
|
|
392
|
+
: "fresh"),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return await this.#persistConversationIdentity(observed, { allowFresh });
|
|
397
|
+
});
|
|
398
|
+
this.conversationIdentityQueue = operation;
|
|
399
|
+
try {
|
|
400
|
+
return await operation;
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (!suppressErrors) {
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
179
406
|
}
|
|
180
407
|
}
|
|
181
408
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// the
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
409
|
+
// Retry only when the adapter proves submission never started. Once the Send
|
|
410
|
+
// action has occurred, a dead transport or missing bubble is commit-unknown;
|
|
411
|
+
// replaying the payload could duplicate provider work and attachments.
|
|
412
|
+
async #sendMessageWithReconnect(message, {
|
|
413
|
+
files,
|
|
414
|
+
maxBytes,
|
|
415
|
+
allowFreshReconnect,
|
|
416
|
+
allowAssistantContinuation,
|
|
417
|
+
outboundId,
|
|
418
|
+
}) {
|
|
188
419
|
try {
|
|
189
|
-
await this.adapter.sendMessage(message, {
|
|
420
|
+
return await this.adapter.sendMessage(message, {
|
|
421
|
+
files,
|
|
422
|
+
maxBytes,
|
|
423
|
+
outboundId,
|
|
424
|
+
allowAssistantContinuation,
|
|
425
|
+
});
|
|
190
426
|
} catch (error) {
|
|
191
427
|
if (isConnectionLostError(error)) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
428
|
+
const sendStatus = this.adapter.getLastSendStatus?.() ?? "commit-unknown";
|
|
429
|
+
if (sendStatus !== "not-submitted") {
|
|
430
|
+
throw new BrowserAdapterError(
|
|
431
|
+
`${this.adapter.providerName ?? "Web provider"} connection was lost after submission may have started. `
|
|
432
|
+
+ "The message was not sent again automatically.",
|
|
433
|
+
{
|
|
434
|
+
code: "SEND_COMMIT_UNKNOWN",
|
|
435
|
+
recoverable: false,
|
|
436
|
+
cause: error,
|
|
437
|
+
},
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
await this.#reconnectAndRestore({
|
|
441
|
+
allowFresh: allowFreshReconnect,
|
|
442
|
+
checkpointIdentity: (restoration) => (
|
|
443
|
+
this.#refreshPendingOutboundIdentity(
|
|
444
|
+
outboundId,
|
|
445
|
+
restoration,
|
|
446
|
+
{ required: true },
|
|
447
|
+
)
|
|
448
|
+
),
|
|
449
|
+
});
|
|
450
|
+
return await this.adapter.sendMessage(message, {
|
|
451
|
+
files,
|
|
452
|
+
maxBytes,
|
|
453
|
+
outboundId,
|
|
454
|
+
allowAssistantContinuation,
|
|
455
|
+
});
|
|
199
456
|
}
|
|
200
457
|
throw error;
|
|
201
458
|
}
|
|
202
459
|
}
|
|
203
460
|
|
|
204
|
-
// Reconnects to the still-alive Chrome and
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
461
|
+
// Reconnects to the still-alive Chrome and restores the exact page target when
|
|
462
|
+
// possible. Once a context-dependent message has been sent, landing on a fresh
|
|
463
|
+
// page is never a safe restoration.
|
|
464
|
+
async #reconnectAndRestore({
|
|
465
|
+
allowFresh = false,
|
|
466
|
+
checkpointIdentity = null,
|
|
467
|
+
} = {}) {
|
|
468
|
+
// Navigation callbacks must not persist a replacement root target without
|
|
469
|
+
// the pending record that causally owns it. Suppress them until the caller's
|
|
470
|
+
// checkpoint writes both identities in one Session transition.
|
|
471
|
+
this.deferConversationIdentityPersistence = true;
|
|
472
|
+
try {
|
|
473
|
+
await this.conversationIdentityQueue.catch(() => null);
|
|
474
|
+
await this.adapter.reconnect?.(
|
|
475
|
+
this.session.state.conversationUrl,
|
|
476
|
+
{ preferredTargetId: this.session.state.conversationTargetId ?? null },
|
|
477
|
+
);
|
|
478
|
+
const restoration = await this.adapter.startConversation(
|
|
479
|
+
this.session.state.conversationUrl,
|
|
480
|
+
{
|
|
481
|
+
expectedAssistantMessageId: this.session.state.lastAssistantMessageId
|
|
482
|
+
?? null,
|
|
483
|
+
expectedUserMessageId: this.session.state.lastUserMessageId ?? null,
|
|
484
|
+
},
|
|
485
|
+
);
|
|
486
|
+
if (
|
|
487
|
+
restoration?.status !== "restored-existing"
|
|
488
|
+
&& !(allowFresh && restoration?.status === "verified-fresh")
|
|
489
|
+
) {
|
|
490
|
+
throw new BrowserAdapterError(
|
|
491
|
+
`${this.adapter.providerName} conversation disappeared while a turn was in progress. `
|
|
492
|
+
+ "Refusing to continue on a blank page; resume the session to recover safely.",
|
|
493
|
+
{
|
|
494
|
+
code: "CONVERSATION_RESTORE_REQUIRED",
|
|
495
|
+
recoverable: false,
|
|
496
|
+
details: { restorationStatus: restoration?.status ?? null },
|
|
497
|
+
},
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const checkpoint = checkpointIdentity
|
|
501
|
+
? await checkpointIdentity(restoration)
|
|
502
|
+
: null;
|
|
503
|
+
return { restoration, checkpoint };
|
|
504
|
+
} finally {
|
|
505
|
+
this.deferConversationIdentityPersistence = false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async #freshRebuildBlockers({ instruction = null } = {}) {
|
|
510
|
+
const state = this.session.state;
|
|
511
|
+
const blockers = [];
|
|
512
|
+
if (state.pendingOutbound != null) {
|
|
513
|
+
blockers.push("unconfirmed outbound message");
|
|
514
|
+
}
|
|
515
|
+
if (state.pendingAssistantTurn != null) {
|
|
516
|
+
blockers.push("pending assistant handoff");
|
|
517
|
+
}
|
|
518
|
+
if (state.pendingToolResult != null) {
|
|
519
|
+
blockers.push("pending tool result");
|
|
520
|
+
}
|
|
521
|
+
if (Object.keys(state.completedTools ?? {}).length > 0) {
|
|
522
|
+
blockers.push("completed tool history");
|
|
523
|
+
}
|
|
524
|
+
if (Object.keys(state.sideEffectTools ?? {}).length > 0) {
|
|
525
|
+
blockers.push("side-effect history");
|
|
526
|
+
}
|
|
527
|
+
if (state.lastMessage != null) {
|
|
528
|
+
blockers.push("completed assistant result");
|
|
529
|
+
}
|
|
530
|
+
if (
|
|
531
|
+
state.lastAssistantMessageId != null
|
|
532
|
+
|| await this.session.hasEvent("model.message_complete")
|
|
533
|
+
) {
|
|
534
|
+
blockers.push("completed or ambiguous assistant response");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const suppliedInstruction = instruction?.trim() ?? "";
|
|
538
|
+
const followUps = state.followUps ?? [];
|
|
539
|
+
const persistedInstructions = [
|
|
540
|
+
...new Set(followUps.map((item) => item?.instruction?.trim()).filter(Boolean)),
|
|
541
|
+
];
|
|
542
|
+
const followUpsHaveAttachments = followUps.some((item) =>
|
|
543
|
+
item?.attachments != null
|
|
544
|
+
&& (!Array.isArray(item.attachments) || item.attachments.length > 0)
|
|
214
545
|
);
|
|
546
|
+
if (
|
|
547
|
+
followUpsHaveAttachments
|
|
548
|
+
|| persistedInstructions.length > 1
|
|
549
|
+
|| (
|
|
550
|
+
suppliedInstruction
|
|
551
|
+
&& persistedInstructions.length === 1
|
|
552
|
+
&& persistedInstructions[0] !== suppliedInstruction
|
|
553
|
+
)
|
|
554
|
+
) {
|
|
555
|
+
blockers.push("earlier follow-up history");
|
|
556
|
+
}
|
|
557
|
+
const rebuildInstruction = suppliedInstruction
|
|
558
|
+
|| (persistedInstructions.length === 1 ? persistedInstructions[0] : null);
|
|
559
|
+
|
|
560
|
+
const transcript = await this.session.readTranscript();
|
|
561
|
+
if (transcript.items.length !== 1) {
|
|
562
|
+
blockers.push(
|
|
563
|
+
transcript.items.length === 0
|
|
564
|
+
? "missing opening transcript"
|
|
565
|
+
: "canonical transcript history",
|
|
566
|
+
);
|
|
567
|
+
} else {
|
|
568
|
+
const item = transcript.items[0].item;
|
|
569
|
+
const hasNoAttachments = item?.attachments == null
|
|
570
|
+
|| (Array.isArray(item.attachments) && item.attachments.length === 0);
|
|
571
|
+
const isOriginalUser = item?.type === "message"
|
|
572
|
+
&& item.role === "user"
|
|
573
|
+
&& messageText(item) === String(state.task ?? "")
|
|
574
|
+
&& hasNoAttachments;
|
|
575
|
+
if (!isOriginalUser) {
|
|
576
|
+
blockers.push("canonical transcript history");
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return { blockers, transcript, rebuildInstruction };
|
|
215
580
|
}
|
|
216
581
|
|
|
217
582
|
buildToolResultMessage(result, { suffix = "" } = {}) {
|
|
218
583
|
const limit = this.limits.maxBrowserToolResultBytes;
|
|
219
|
-
const nonResultBytes = utf8ByteLength(appendSystemReminder(suffix))
|
|
584
|
+
const nonResultBytes = utf8ByteLength(appendSystemReminder(suffix))
|
|
585
|
+
+ OUTBOUND_CORRELATION_RESERVE_BYTES;
|
|
220
586
|
const resultBudget = limit - nonResultBytes;
|
|
221
587
|
if (resultBudget < 512) {
|
|
222
588
|
throw new RangeError(
|
|
@@ -226,10 +592,223 @@ export class AgentRuntime {
|
|
|
226
592
|
return `${serializeToolResult(result, { maxBytes: resultBudget })}${suffix}`;
|
|
227
593
|
}
|
|
228
594
|
|
|
229
|
-
async sendToolResult(result, {
|
|
230
|
-
|
|
231
|
-
|
|
595
|
+
async sendToolResult(result, {
|
|
596
|
+
suffix = "",
|
|
597
|
+
runtimeTurn = null,
|
|
598
|
+
} = {}) {
|
|
599
|
+
return await this.sendMessage(
|
|
600
|
+
this.buildToolResultMessage(result, { suffix }),
|
|
601
|
+
{
|
|
602
|
+
maxBytes: this.limits.maxBrowserToolResultBytes,
|
|
603
|
+
outboundKind: "tool_result",
|
|
604
|
+
runtimeTurn,
|
|
605
|
+
pendingToolAcknowledgement: {
|
|
606
|
+
callId: result.callId ?? null,
|
|
607
|
+
name: result.name ?? null,
|
|
608
|
+
operationSignature: result.operationSignature ?? null,
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async #appendHandoffTranscript(activeHandoff, suffix, item) {
|
|
615
|
+
if (!activeHandoff?.handoffId) {
|
|
616
|
+
throw new Error("Cannot append transcript without an assistant handoff ID.");
|
|
617
|
+
}
|
|
618
|
+
return await this.session.appendTranscriptItemOnce(item, {
|
|
619
|
+
idempotencyKey: `${activeHandoff.handoffId}:${suffix}`,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
#inferPendingRuntimeTurn(pendingOutbound) {
|
|
624
|
+
if (Number.isSafeInteger(pendingOutbound?.runtimeTurn)) {
|
|
625
|
+
return pendingOutbound.runtimeTurn;
|
|
626
|
+
}
|
|
627
|
+
const currentTurn = Number(this.session.state.turn || 0);
|
|
628
|
+
if ([
|
|
629
|
+
"empty_response_recovery",
|
|
630
|
+
"dead_request_recovery",
|
|
631
|
+
"generation_failed_recovery",
|
|
632
|
+
].includes(pendingOutbound?.kind)) {
|
|
633
|
+
return Math.max(1, currentTurn);
|
|
634
|
+
}
|
|
635
|
+
return currentTurn + 1;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
#recoveryHandoffPatch(outcome) {
|
|
639
|
+
return {
|
|
640
|
+
conversationUrl: outcome.conversationUrl,
|
|
641
|
+
conversationTargetId: outcome.conversationTargetId,
|
|
642
|
+
userMessageId: outcome.userMessageId,
|
|
643
|
+
userTurn: outcome.userTurn,
|
|
644
|
+
preOutboundMarkerIds: outcome.preOutboundMarkerIds ?? [],
|
|
645
|
+
assistantBaseline: outcome.assistantBaseline ?? null,
|
|
646
|
+
assistantCandidateMessageId:
|
|
647
|
+
outcome.assistantCandidateMessageId ?? null,
|
|
648
|
+
assistantCandidateTurn: outcome.assistantCandidateTurn ?? null,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async #refreshHandoffAfterRestore(handoff, restoration) {
|
|
653
|
+
const conversationUrl = restoration?.conversationUrl
|
|
654
|
+
?? this.session.state.conversationUrl
|
|
655
|
+
?? handoff.conversationUrl;
|
|
656
|
+
const conversationTargetId = restoration?.targetId
|
|
657
|
+
?? this.session.state.conversationTargetId
|
|
658
|
+
?? handoff.conversationTargetId;
|
|
659
|
+
const refreshed = await this.#queueSessionOperation(() => (
|
|
660
|
+
this.session.refreshPendingAssistantTurn(handoff.handoffId, {
|
|
661
|
+
conversationUrl,
|
|
662
|
+
conversationTargetId,
|
|
663
|
+
})
|
|
664
|
+
));
|
|
665
|
+
this.adapter.acceptRecoveredOutboundAnchor?.({
|
|
666
|
+
conversationUrl,
|
|
667
|
+
conversationTargetId,
|
|
668
|
+
userMessageId: refreshed.userMessageId,
|
|
669
|
+
userTurn: refreshed.userTurn,
|
|
670
|
+
assistantBaseline: refreshed.assistantBaseline,
|
|
671
|
+
assistantCandidateMessageId:
|
|
672
|
+
refreshed.assistantCandidateMessageId ?? null,
|
|
673
|
+
assistantCandidateTurn: refreshed.assistantCandidateTurn ?? null,
|
|
232
674
|
});
|
|
675
|
+
return refreshed;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async #recoverPendingConversation({ pendingOutbound, pendingAssistantTurn }) {
|
|
679
|
+
const outboundId = pendingOutbound?.outboundId
|
|
680
|
+
?? pendingAssistantTurn?.sourceOutboundId
|
|
681
|
+
?? null;
|
|
682
|
+
if (!isValidOutboundCorrelationId(outboundId)) {
|
|
683
|
+
throw new BrowserAdapterError(
|
|
684
|
+
"The pending browser handoff has no valid outbound correlation ID.",
|
|
685
|
+
{ code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
if (!this.adapter.supportsPendingOutboundRecovery?.()) {
|
|
689
|
+
throw new BrowserAdapterError(
|
|
690
|
+
`${this.adapter.providerName ?? "Web provider"} does not support safe pending outbound recovery.`,
|
|
691
|
+
{ code: "OUTBOUND_RECOVERY_UNSUPPORTED", recoverable: false },
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
const boundTargetId = pendingOutbound?.conversationTargetId
|
|
695
|
+
?? (!pendingOutbound
|
|
696
|
+
? pendingAssistantTurn?.conversationTargetId ?? null
|
|
697
|
+
: null);
|
|
698
|
+
const rootTargetId = this.session.state.conversationTargetId ?? null;
|
|
699
|
+
if (
|
|
700
|
+
boundTargetId
|
|
701
|
+
&& rootTargetId
|
|
702
|
+
&& boundTargetId !== rootTargetId
|
|
703
|
+
) {
|
|
704
|
+
throw new BrowserAdapterError(
|
|
705
|
+
"The pending browser handoff target conflicts with the Session target. Refusing to choose either target.",
|
|
706
|
+
{ code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
const targetId = boundTargetId ?? rootTargetId;
|
|
710
|
+
if (!targetId) {
|
|
711
|
+
throw new BrowserAdapterError(
|
|
712
|
+
"Pending outbound recovery requires the exact saved browser target.",
|
|
713
|
+
{ code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
await this.adapter.launchRecoveryTarget(targetId);
|
|
718
|
+
await this.emit("browser.started", {
|
|
719
|
+
profileDir: this.adapter.profileDir,
|
|
720
|
+
recovery: true,
|
|
721
|
+
targetId,
|
|
722
|
+
});
|
|
723
|
+
const priorForReconciliation = pendingOutbound
|
|
724
|
+
&& pendingAssistantTurn?.sourceOutboundId !== pendingOutbound.outboundId
|
|
725
|
+
? null
|
|
726
|
+
: pendingAssistantTurn;
|
|
727
|
+
const outcome = await this.adapter.reconcilePendingOutbound({
|
|
728
|
+
pendingOutbound,
|
|
729
|
+
priorHandoff: priorForReconciliation,
|
|
730
|
+
conversationTargetId: targetId,
|
|
731
|
+
lastUserMessageId: this.session.state.lastUserMessageId ?? null,
|
|
732
|
+
lastAssistantMessageId:
|
|
733
|
+
this.session.state.lastAssistantMessageId ?? null,
|
|
734
|
+
});
|
|
735
|
+
if (!["waiting", "complete"].includes(outcome?.status)) {
|
|
736
|
+
throw new BrowserAdapterError(
|
|
737
|
+
"Pending outbound recovery did not return a durable assistant boundary.",
|
|
738
|
+
{ code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
const outcomeKind = outcome.conversationUrl
|
|
742
|
+
? this.adapter.classifyConversationUrl?.(outcome.conversationUrl)
|
|
743
|
+
: null;
|
|
744
|
+
if (
|
|
745
|
+
outcome.conversationTargetId !== targetId
|
|
746
|
+
|| !["restorable", "provisional"].includes(outcomeKind)
|
|
747
|
+
) {
|
|
748
|
+
throw new BrowserAdapterError(
|
|
749
|
+
"Pending outbound recovery returned a different browser target or conversation route.",
|
|
750
|
+
{ code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
let handoff = pendingAssistantTurn;
|
|
755
|
+
if (pendingOutbound) {
|
|
756
|
+
const pendingToolAcknowledgement =
|
|
757
|
+
pendingOutbound.pendingToolAcknowledgement
|
|
758
|
+
?? (["tool_result", "pending_tool_result"].includes(pendingOutbound.kind)
|
|
759
|
+
&& this.session.state.pendingToolResult
|
|
760
|
+
? {
|
|
761
|
+
callId: this.session.state.pendingToolResult.callId ?? null,
|
|
762
|
+
name: this.session.state.pendingToolResult.name ?? null,
|
|
763
|
+
operationSignature:
|
|
764
|
+
this.session.state.pendingToolResult.operationSignature ?? null,
|
|
765
|
+
}
|
|
766
|
+
: null);
|
|
767
|
+
handoff = await this.#queueSessionOperation(() => (
|
|
768
|
+
this.session.commitPendingOutboundHandoff({
|
|
769
|
+
outboundId: pendingOutbound.outboundId,
|
|
770
|
+
handoff: {
|
|
771
|
+
...this.#recoveryHandoffPatch(outcome),
|
|
772
|
+
runtimeTurn: this.#inferPendingRuntimeTurn(pendingOutbound),
|
|
773
|
+
pendingToolAcknowledgement,
|
|
774
|
+
},
|
|
775
|
+
})
|
|
776
|
+
));
|
|
777
|
+
await this.emit("outbound.reconciled", {
|
|
778
|
+
outboundId: pendingOutbound.outboundId,
|
|
779
|
+
kind: pendingOutbound.kind ?? null,
|
|
780
|
+
status: outcome.status,
|
|
781
|
+
userMessageId: outcome.userMessageId ?? null,
|
|
782
|
+
});
|
|
783
|
+
} else if (handoff) {
|
|
784
|
+
handoff = await this.#queueSessionOperation(() => (
|
|
785
|
+
this.session.refreshPendingAssistantTurn(
|
|
786
|
+
handoff.handoffId,
|
|
787
|
+
this.#recoveryHandoffPatch(outcome),
|
|
788
|
+
)
|
|
789
|
+
));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (outcome.status === "complete") {
|
|
793
|
+
const outcomeHash = createHash("sha256")
|
|
794
|
+
.update(outcome.rawResponse)
|
|
795
|
+
.digest("hex");
|
|
796
|
+
if (outcome.responseHash && outcome.responseHash !== outcomeHash) {
|
|
797
|
+
throw new BrowserAdapterError(
|
|
798
|
+
"Recovered assistant response hash does not match its raw content.",
|
|
799
|
+
{ code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
handoff = await this.#queueSessionOperation(() => (
|
|
803
|
+
this.session.completePendingAssistantTurn({
|
|
804
|
+
handoffId: handoff.handoffId,
|
|
805
|
+
assistantMessageId: outcome.assistantMessageId ?? null,
|
|
806
|
+
assistantTurn: outcome.assistantTurn ?? null,
|
|
807
|
+
rawResponse: outcome.rawResponse,
|
|
808
|
+
})
|
|
809
|
+
));
|
|
810
|
+
}
|
|
811
|
+
return handoff;
|
|
233
812
|
}
|
|
234
813
|
|
|
235
814
|
async run({
|
|
@@ -243,8 +822,50 @@ export class AgentRuntime {
|
|
|
243
822
|
projectRoot,
|
|
244
823
|
} = this.session.state;
|
|
245
824
|
const previousConversationUrl = this.session.state.conversationUrl;
|
|
246
|
-
|
|
247
|
-
const
|
|
825
|
+
const pendingOutboundAtStart = this.session.state.pendingOutbound;
|
|
826
|
+
const pendingAssistantAtStart = this.session.state.pendingAssistantTurn;
|
|
827
|
+
const hasPendingRecovery = pendingOutboundAtStart != null
|
|
828
|
+
|| pendingAssistantAtStart != null;
|
|
829
|
+
const supportsStrictRecovery = Boolean(
|
|
830
|
+
this.adapter.supportsPendingOutboundRecovery?.(),
|
|
831
|
+
);
|
|
832
|
+
const strictRecoveryAtStart = pendingOutboundAtStart != null
|
|
833
|
+
|| (pendingAssistantAtStart != null && supportsStrictRecovery);
|
|
834
|
+
const legacyAssistantRecoveryAtStart = pendingOutboundAtStart == null
|
|
835
|
+
&& pendingAssistantAtStart != null
|
|
836
|
+
&& !supportsStrictRecovery;
|
|
837
|
+
if (
|
|
838
|
+
hasPendingRecovery
|
|
839
|
+
&& (
|
|
840
|
+
!resume
|
|
841
|
+
|| Boolean(instruction?.trim())
|
|
842
|
+
|| (files ?? []).length > 0
|
|
843
|
+
)
|
|
844
|
+
) {
|
|
845
|
+
throw new BrowserAdapterError(
|
|
846
|
+
"A pending browser handoff can only be recovered with a bare resume. "
|
|
847
|
+
+ "Do not add an instruction or attachment until recovery completes.",
|
|
848
|
+
{ code: "RECOVERY_REQUIRES_BARE_RESUME", recoverable: false },
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
const attachments = (files ?? []).map((file) => ({
|
|
852
|
+
name: file.name ?? null,
|
|
853
|
+
path: file.path ?? null,
|
|
854
|
+
}));
|
|
855
|
+
const messageOptions = attachments.length > 0 ? { attachments } : {};
|
|
856
|
+
|
|
857
|
+
// Persist the opening user intent (including attachment metadata) before any
|
|
858
|
+
// browser work. A later recovery can then distinguish a replayable untouched
|
|
859
|
+
// task from an unknowable empty legacy rollout.
|
|
860
|
+
if (!resume) {
|
|
861
|
+
const transcript = await this.session.readTranscript();
|
|
862
|
+
if (transcript.items.length === 0) {
|
|
863
|
+
await this.session.appendTranscriptItemOnce(
|
|
864
|
+
userMessage(task, messageOptions),
|
|
865
|
+
{ idempotencyKey: `session:${this.session.sessionId}:opening` },
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
248
869
|
|
|
249
870
|
await this.session.update({
|
|
250
871
|
phase: "initializing",
|
|
@@ -253,10 +874,49 @@ export class AgentRuntime {
|
|
|
253
874
|
});
|
|
254
875
|
await this.emit("runtime.initializing");
|
|
255
876
|
|
|
877
|
+
const modelTurnTimeoutMs = this.limits.modelTurnTimeoutMs;
|
|
878
|
+
let pendingToolResult;
|
|
879
|
+
let awaitingPendingAcknowledgement = false;
|
|
880
|
+
let replayGuard = null;
|
|
881
|
+
let protocolErrors = 0;
|
|
882
|
+
const baseTurn = resume ? Number(this.session.state.turn || 0) : 0;
|
|
883
|
+
|
|
884
|
+
if (strictRecoveryAtStart) {
|
|
885
|
+
const recoveredHandoff = await this.#recoverPendingConversation({
|
|
886
|
+
pendingOutbound: pendingOutboundAtStart,
|
|
887
|
+
pendingAssistantTurn: pendingAssistantAtStart,
|
|
888
|
+
});
|
|
889
|
+
// Recovery proves the browser boundary before touching a possibly-running
|
|
890
|
+
// side-effect ledger. The existing local exactly-once rules then apply.
|
|
891
|
+
await this.session.recoverInterruptedSideEffects();
|
|
892
|
+
pendingToolResult = this.session.state.pendingToolResult;
|
|
893
|
+
awaitingPendingAcknowledgement = Boolean(
|
|
894
|
+
recoveredHandoff.pendingToolAcknowledgement,
|
|
895
|
+
);
|
|
896
|
+
replayGuard = pendingToolResult?.operationSignature
|
|
897
|
+
? {
|
|
898
|
+
signature: pendingToolResult.operationSignature,
|
|
899
|
+
result: pendingToolResult,
|
|
900
|
+
}
|
|
901
|
+
: null;
|
|
902
|
+
await this.session.update({
|
|
903
|
+
phase: "running",
|
|
904
|
+
activeMode: null,
|
|
905
|
+
});
|
|
906
|
+
await this.emit("conversation.started", {
|
|
907
|
+
url: recoveredHandoff.conversationUrl,
|
|
908
|
+
recovered: true,
|
|
909
|
+
});
|
|
910
|
+
} else {
|
|
256
911
|
// On resume, prefer an existing tab already showing the conversation so
|
|
257
912
|
// repeated resumes reuse the same tab instead of piling up new ones.
|
|
258
913
|
await this.adapter.launch(
|
|
259
914
|
resume ? this.session.state.conversationUrl : null,
|
|
915
|
+
{
|
|
916
|
+
preferredTargetId: resume
|
|
917
|
+
? this.session.state.conversationTargetId ?? null
|
|
918
|
+
: null,
|
|
919
|
+
},
|
|
260
920
|
);
|
|
261
921
|
await this.emit("browser.started", {
|
|
262
922
|
profileDir: this.adapter.profileDir,
|
|
@@ -288,47 +948,150 @@ export class AgentRuntime {
|
|
|
288
948
|
await this.emit("browser.authenticated");
|
|
289
949
|
}
|
|
290
950
|
|
|
291
|
-
await this.adapter.startConversation(
|
|
951
|
+
let restoration = await this.adapter.startConversation(
|
|
292
952
|
resume ? previousConversationUrl : null,
|
|
293
953
|
{
|
|
294
954
|
expectedAssistantMessageId: resume
|
|
295
955
|
? this.session.state.lastAssistantMessageId
|
|
296
956
|
: null,
|
|
957
|
+
expectedUserMessageId: resume
|
|
958
|
+
? this.session.state.lastUserMessageId
|
|
959
|
+
: null,
|
|
297
960
|
},
|
|
298
961
|
);
|
|
962
|
+
if (
|
|
963
|
+
restoration?.status !== "restored-existing"
|
|
964
|
+
&& restoration?.status !== "verified-fresh"
|
|
965
|
+
) {
|
|
966
|
+
throw new BrowserAdapterError(
|
|
967
|
+
`${this.adapter.providerName} did not report whether conversation history was restored.`,
|
|
968
|
+
{
|
|
969
|
+
code: "CONVERSATION_RESTORE_UNVERIFIED",
|
|
970
|
+
recoverable: false,
|
|
971
|
+
},
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
if (
|
|
975
|
+
legacyAssistantRecoveryAtStart
|
|
976
|
+
&& restoration.status !== "restored-existing"
|
|
977
|
+
) {
|
|
978
|
+
throw new BrowserAdapterError(
|
|
979
|
+
`${this.adapter.providerName} could not restore the confirmed pending assistant turn. `
|
|
980
|
+
+ "Refusing to continue it on a blank conversation.",
|
|
981
|
+
{
|
|
982
|
+
code: "CONVERSATION_RESTORE_REQUIRED",
|
|
983
|
+
recoverable: false,
|
|
984
|
+
},
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
await this.#syncConversationIdentity({
|
|
988
|
+
identity: {
|
|
989
|
+
conversationUrl: restoration.conversationUrl,
|
|
990
|
+
targetId: restoration.targetId ?? null,
|
|
991
|
+
kind: restoration.status === "verified-fresh"
|
|
992
|
+
? "fresh"
|
|
993
|
+
: this.adapter.classifyConversationUrl?.(restoration.conversationUrl),
|
|
994
|
+
},
|
|
995
|
+
allowFresh: restoration.status === "verified-fresh",
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
const resumedExistingConversation = resume
|
|
999
|
+
&& restoration.status === "restored-existing";
|
|
1000
|
+
const rebuildingFreshConversation = resume
|
|
1001
|
+
&& restoration.status === "verified-fresh";
|
|
1002
|
+
let effectiveInstruction = instruction;
|
|
1003
|
+
if (rebuildingFreshConversation) {
|
|
1004
|
+
const {
|
|
1005
|
+
blockers,
|
|
1006
|
+
rebuildInstruction,
|
|
1007
|
+
} = await this.#freshRebuildBlockers({ instruction });
|
|
1008
|
+
if (blockers.length > 0) {
|
|
1009
|
+
throw new BrowserAdapterError(
|
|
1010
|
+
`${this.adapter.providerName} opened an empty conversation, but this session has `
|
|
1011
|
+
+ `history that cannot be replayed safely (${blockers.join(", ")}). `
|
|
1012
|
+
+ "The browser message was not sent.",
|
|
1013
|
+
{
|
|
1014
|
+
code: "CONVERSATION_REBUILD_UNSAFE",
|
|
1015
|
+
recoverable: false,
|
|
1016
|
+
details: { blockers },
|
|
1017
|
+
},
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
effectiveInstruction = rebuildInstruction;
|
|
1021
|
+
await this.emit("conversation.rebuilding_fresh", {
|
|
1022
|
+
previousUrl: previousConversationUrl,
|
|
1023
|
+
url: restoration.conversationUrl,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Only normalize an interrupted side-effect ledger after the browser
|
|
1028
|
+
// restoration decision. An unsafe fresh-page fallback must leave every
|
|
1029
|
+
// pending/result/side-effect record untouched for deliberate inspection.
|
|
1030
|
+
await this.session.recoverInterruptedSideEffects();
|
|
1031
|
+
pendingToolResult = this.session.state.pendingToolResult;
|
|
299
1032
|
|
|
300
1033
|
// Optional setup after authentication and the target conversation are
|
|
301
1034
|
// ready, but before the first message is sent. Interactive CLI runs use
|
|
302
1035
|
// this to let the user choose a model directly on the provider website.
|
|
303
1036
|
// WTAgent never names, selects, or overrides a provider-specific model.
|
|
304
|
-
//
|
|
305
|
-
|
|
1037
|
+
// Existing conversations keep their model context. A verified fresh rebuild
|
|
1038
|
+
// is a genuinely new web chat, so interactive runs offer the same browser-side
|
|
1039
|
+
// model choice as an ordinary bootstrap.
|
|
1040
|
+
if (restoration.status === "verified-fresh" && this.postAuthSetup) {
|
|
306
1041
|
await this.postAuthSetup({ adapter: this.adapter });
|
|
1042
|
+
// Browser-side setup is user-controlled and can navigate the tab. Reopen
|
|
1043
|
+
// and re-verify an empty chat after it returns so the bootstrap can never
|
|
1044
|
+
// land in a history conversation selected during setup.
|
|
1045
|
+
restoration = await this.adapter.startConversation();
|
|
1046
|
+
if (restoration?.status !== "verified-fresh") {
|
|
1047
|
+
throw new BrowserAdapterError(
|
|
1048
|
+
`${this.adapter.providerName} did not remain on a verified empty conversation after browser setup.`,
|
|
1049
|
+
{
|
|
1050
|
+
code: "CONVERSATION_NOT_FRESH",
|
|
1051
|
+
recoverable: false,
|
|
1052
|
+
},
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
await this.#syncConversationIdentity({
|
|
1056
|
+
identity: {
|
|
1057
|
+
conversationUrl: restoration.conversationUrl,
|
|
1058
|
+
targetId: restoration.targetId ?? null,
|
|
1059
|
+
kind: "fresh",
|
|
1060
|
+
},
|
|
1061
|
+
allowFresh: true,
|
|
1062
|
+
});
|
|
307
1063
|
}
|
|
308
1064
|
|
|
309
|
-
const modelTurnTimeoutMs = this.limits.modelTurnTimeoutMs;
|
|
310
|
-
|
|
311
1065
|
await this.session.update({
|
|
312
1066
|
phase: "running",
|
|
313
|
-
conversationUrl: await this.adapter.getConversationUrl(),
|
|
314
1067
|
activeMode: null,
|
|
315
1068
|
});
|
|
316
1069
|
await this.emit("conversation.started", {
|
|
317
1070
|
url: this.session.state.conversationUrl,
|
|
318
1071
|
});
|
|
319
1072
|
|
|
1073
|
+
if (legacyAssistantRecoveryAtStart) {
|
|
1074
|
+
const recoveredHandoff = await this.#refreshHandoffAfterRestore(
|
|
1075
|
+
pendingAssistantAtStart,
|
|
1076
|
+
restoration,
|
|
1077
|
+
);
|
|
1078
|
+
awaitingPendingAcknowledgement = Boolean(
|
|
1079
|
+
recoveredHandoff.pendingToolAcknowledgement,
|
|
1080
|
+
);
|
|
1081
|
+
replayGuard = pendingToolResult?.operationSignature
|
|
1082
|
+
? {
|
|
1083
|
+
signature: pendingToolResult.operationSignature,
|
|
1084
|
+
result: pendingToolResult,
|
|
1085
|
+
}
|
|
1086
|
+
: null;
|
|
1087
|
+
}
|
|
320
1088
|
let initialMessage;
|
|
321
1089
|
let initialKind;
|
|
322
1090
|
// The web transport gets `.web` (XML/marked text). The portable rollout
|
|
323
|
-
// records only
|
|
1091
|
+
// records only confirmed user messages; WTAgent scaffolding stays transport-only.
|
|
324
1092
|
let initialTranscript = [];
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
name: file.name ?? null,
|
|
328
|
-
path: file.path ?? null,
|
|
329
|
-
}));
|
|
330
|
-
const messageOptions = attachments.length > 0 ? { attachments } : {};
|
|
331
|
-
if (resume && pendingToolResult && !inPlaceRecovery) {
|
|
1093
|
+
if (!legacyAssistantRecoveryAtStart) {
|
|
1094
|
+
if (resumedExistingConversation && pendingToolResult && !inPlaceRecovery) {
|
|
332
1095
|
let suffix = "";
|
|
333
1096
|
if (instruction?.trim()) {
|
|
334
1097
|
suffix = `\n<resume_instruction>${cdata(instruction)}</resume_instruction>`;
|
|
@@ -336,7 +1099,7 @@ export class AgentRuntime {
|
|
|
336
1099
|
}
|
|
337
1100
|
initialMessage = this.buildToolResultMessage(pendingToolResult, { suffix });
|
|
338
1101
|
initialKind = "pending_tool_result";
|
|
339
|
-
} else if (
|
|
1102
|
+
} else if (resumedExistingConversation && instruction?.trim()) {
|
|
340
1103
|
// The live web conversation already contains the bootstrap protocol
|
|
341
1104
|
// and tool catalog. A normal follow-up should be the user's message, not
|
|
342
1105
|
// another several-thousand-character protocol bootstrap. sendMessage()
|
|
@@ -344,7 +1107,7 @@ export class AgentRuntime {
|
|
|
344
1107
|
initialMessage = instruction.trim();
|
|
345
1108
|
initialTranscript = [userMessage(instruction.trim(), messageOptions)];
|
|
346
1109
|
initialKind = "follow_up";
|
|
347
|
-
} else if (
|
|
1110
|
+
} else if (resumedExistingConversation && inPlaceRecovery) {
|
|
348
1111
|
// The original request/tool result is already visible in this live web
|
|
349
1112
|
// conversation. Ask the provider to continue without duplicating transport
|
|
350
1113
|
// payloads, attachments, or canonical transcript entries.
|
|
@@ -352,13 +1115,21 @@ export class AgentRuntime {
|
|
|
352
1115
|
initialKind = "empty_response_recovery";
|
|
353
1116
|
} else if (resume) {
|
|
354
1117
|
const prompt = buildResumePrompt({
|
|
355
|
-
instruction
|
|
1118
|
+
instruction: rebuildingFreshConversation
|
|
1119
|
+
? effectiveInstruction
|
|
1120
|
+
: instruction,
|
|
356
1121
|
state: this.session.state,
|
|
357
1122
|
tools: this.registry.list(),
|
|
358
1123
|
});
|
|
359
1124
|
initialMessage = prompt.web;
|
|
360
|
-
|
|
361
|
-
|
|
1125
|
+
// The original task is already the one safe canonical item required by the
|
|
1126
|
+
// rebuild gate. Record only a newly confirmed follow-up instruction.
|
|
1127
|
+
initialTranscript = rebuildingFreshConversation
|
|
1128
|
+
? (effectiveInstruction?.trim()
|
|
1129
|
+
? [userMessage(effectiveInstruction.trim(), messageOptions)]
|
|
1130
|
+
: [])
|
|
1131
|
+
: [userMessage(prompt.user, messageOptions)];
|
|
1132
|
+
initialKind = rebuildingFreshConversation ? "fresh_rebuild" : "resume";
|
|
362
1133
|
} else {
|
|
363
1134
|
const prompt = buildBootstrapPrompt({
|
|
364
1135
|
task,
|
|
@@ -366,53 +1137,88 @@ export class AgentRuntime {
|
|
|
366
1137
|
tools: this.registry.list(),
|
|
367
1138
|
});
|
|
368
1139
|
initialMessage = prompt.web;
|
|
369
|
-
|
|
1140
|
+
// The opening item was persisted before browser setup above.
|
|
1141
|
+
initialTranscript = [];
|
|
370
1142
|
initialKind = "bootstrap";
|
|
371
1143
|
}
|
|
372
|
-
|
|
373
|
-
for (const item of initialTranscript) {
|
|
374
|
-
await this.session.appendTranscriptItem(item);
|
|
375
1144
|
}
|
|
376
|
-
await this.sendMessage(initialMessage, {
|
|
377
|
-
files,
|
|
378
|
-
maxBytes: initialKind === "pending_tool_result"
|
|
379
|
-
? this.limits.maxBrowserToolResultBytes
|
|
380
|
-
: null,
|
|
381
|
-
});
|
|
382
|
-
let awaitingPendingAcknowledgement = Boolean(pendingToolResult);
|
|
383
|
-
let replayGuard = pendingToolResult?.operationSignature
|
|
384
|
-
? {
|
|
385
|
-
signature: pendingToolResult.operationSignature,
|
|
386
|
-
result: pendingToolResult,
|
|
387
|
-
}
|
|
388
|
-
: null;
|
|
389
|
-
await this.emit("model.message_sent", { kind: initialKind });
|
|
390
1145
|
|
|
391
|
-
|
|
392
|
-
|
|
1146
|
+
if (!legacyAssistantRecoveryAtStart) {
|
|
1147
|
+
await this.sendMessage(initialMessage, {
|
|
1148
|
+
files,
|
|
1149
|
+
maxBytes: initialKind === "pending_tool_result"
|
|
1150
|
+
? this.limits.maxBrowserToolResultBytes
|
|
1151
|
+
: null,
|
|
1152
|
+
// A full bootstrap/rebuild prompt may reconnect only when submission was
|
|
1153
|
+
// proven not to have started. Context-dependent messages never opt in.
|
|
1154
|
+
allowFreshReconnect: restoration.status === "verified-fresh",
|
|
1155
|
+
allowAssistantContinuation: initialKind === "empty_response_recovery",
|
|
1156
|
+
outboundKind: initialKind,
|
|
1157
|
+
transcriptItems: initialTranscript,
|
|
1158
|
+
runtimeTurn: baseTurn + 1,
|
|
1159
|
+
pendingToolAcknowledgement: pendingToolResult
|
|
1160
|
+
? {
|
|
1161
|
+
callId: pendingToolResult.callId ?? null,
|
|
1162
|
+
name: pendingToolResult.name ?? null,
|
|
1163
|
+
operationSignature: pendingToolResult.operationSignature ?? null,
|
|
1164
|
+
}
|
|
1165
|
+
: null,
|
|
1166
|
+
});
|
|
1167
|
+
awaitingPendingAcknowledgement = Boolean(pendingToolResult);
|
|
1168
|
+
replayGuard = pendingToolResult?.operationSignature
|
|
1169
|
+
? {
|
|
1170
|
+
signature: pendingToolResult.operationSignature,
|
|
1171
|
+
result: pendingToolResult,
|
|
1172
|
+
}
|
|
1173
|
+
: null;
|
|
1174
|
+
await this.emit("model.message_sent", { kind: initialKind });
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
393
1177
|
|
|
394
1178
|
for (let step = 1; ; step += 1) {
|
|
395
|
-
|
|
1179
|
+
let activeHandoff = this.session.state.pendingAssistantTurn;
|
|
1180
|
+
if (!activeHandoff) {
|
|
1181
|
+
throw new BrowserAdapterError(
|
|
1182
|
+
"The durable assistant handoff disappeared before response processing.",
|
|
1183
|
+
{ code: "ASSISTANT_HANDOFF_MISSING", recoverable: false },
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
const turnNumber = Number.isSafeInteger(activeHandoff.runtimeTurn)
|
|
1187
|
+
? activeHandoff.runtimeTurn
|
|
1188
|
+
: baseTurn + step;
|
|
396
1189
|
await this.session.update({ turn: turnNumber, phase: "waiting_model" });
|
|
397
1190
|
let raw;
|
|
398
1191
|
let emptyAssistantRetries = 0;
|
|
399
1192
|
let connectionRetries = 0;
|
|
400
1193
|
for (;;) {
|
|
401
1194
|
try {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
1195
|
+
if (activeHandoff.status === "complete") {
|
|
1196
|
+
raw = activeHandoff.rawResponse;
|
|
1197
|
+
} else {
|
|
1198
|
+
try {
|
|
1199
|
+
raw = await this.adapter.waitForTurnComplete({
|
|
1200
|
+
timeoutMs: modelTurnTimeoutMs,
|
|
1201
|
+
stableWindowMs: this.limits.modelStableWindowMs,
|
|
1202
|
+
emptyResponseWindowMs: this.limits.emptyAssistantWindowMs,
|
|
1203
|
+
deadRequestGraceMs: this.limits.deadRequestGraceMs,
|
|
1204
|
+
onDelta: async (delta) => {
|
|
1205
|
+
await this.onEvent?.({
|
|
1206
|
+
type: "model.streaming",
|
|
1207
|
+
sessionId: this.session.sessionId,
|
|
1208
|
+
timestamp: new Date().toISOString(),
|
|
1209
|
+
payload: { delta },
|
|
1210
|
+
});
|
|
1211
|
+
},
|
|
413
1212
|
});
|
|
414
|
-
}
|
|
415
|
-
|
|
1213
|
+
} finally {
|
|
1214
|
+
// Navigation events normally persist canonicalization immediately;
|
|
1215
|
+
// this validated fallback runs on every outcome but never masks a
|
|
1216
|
+
// completed reply or the original browser/model error.
|
|
1217
|
+
await this.#syncConversationIdentity({
|
|
1218
|
+
suppressErrors: true,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
416
1222
|
break;
|
|
417
1223
|
} catch (error) {
|
|
418
1224
|
if (isConnectionLostError(error)) {
|
|
@@ -420,9 +1226,25 @@ export class AgentRuntime {
|
|
|
420
1226
|
throw error;
|
|
421
1227
|
}
|
|
422
1228
|
connectionRetries += 1;
|
|
423
|
-
|
|
1229
|
+
if (supportsStrictRecovery) {
|
|
1230
|
+
await this.adapter.detach?.();
|
|
1231
|
+
activeHandoff = await this.#recoverPendingConversation({
|
|
1232
|
+
pendingOutbound: null,
|
|
1233
|
+
pendingAssistantTurn: activeHandoff,
|
|
1234
|
+
});
|
|
1235
|
+
} else {
|
|
1236
|
+
const { checkpoint } = await this.#reconnectAndRestore({
|
|
1237
|
+
checkpointIdentity: (restoration) => (
|
|
1238
|
+
this.#refreshHandoffAfterRestore(
|
|
1239
|
+
activeHandoff,
|
|
1240
|
+
restoration,
|
|
1241
|
+
)
|
|
1242
|
+
),
|
|
1243
|
+
});
|
|
1244
|
+
activeHandoff = checkpoint;
|
|
1245
|
+
}
|
|
424
1246
|
// The message was already sent before the connection died; resume
|
|
425
|
-
// waiting for the reply on the restored
|
|
1247
|
+
// waiting for the reply on the restored exact conversation boundary.
|
|
426
1248
|
continue;
|
|
427
1249
|
}
|
|
428
1250
|
|
|
@@ -448,7 +1270,6 @@ export class AgentRuntime {
|
|
|
448
1270
|
const emptyAssistantMessageId = await this.adapter
|
|
449
1271
|
.getLastAssistantMessageId?.() ?? null;
|
|
450
1272
|
await this.session.update({
|
|
451
|
-
conversationUrl: await this.adapter.getConversationUrl(),
|
|
452
1273
|
lastAssistantMessageId: emptyAssistantMessageId
|
|
453
1274
|
?? this.session.state.lastAssistantMessageId,
|
|
454
1275
|
});
|
|
@@ -488,13 +1309,25 @@ export class AgentRuntime {
|
|
|
488
1309
|
// Do not resend the original request or tool result: both are already
|
|
489
1310
|
// present in ChatGPT's conversation. This transport-only continuation
|
|
490
1311
|
// also cannot re-execute a local tool by itself.
|
|
491
|
-
await this.sendMessage(
|
|
1312
|
+
const continuationSend = await this.sendMessage(
|
|
492
1313
|
deadRequest
|
|
493
1314
|
? DEAD_REQUEST_CONTINUE_MESSAGE
|
|
494
1315
|
: generationFailed
|
|
495
1316
|
? GENERATION_FAILED_CONTINUE_MESSAGE
|
|
496
1317
|
: EMPTY_ASSISTANT_CONTINUE_MESSAGE,
|
|
1318
|
+
{
|
|
1319
|
+
allowAssistantContinuation: true,
|
|
1320
|
+
outboundKind: deadRequest
|
|
1321
|
+
? "dead_request_recovery"
|
|
1322
|
+
: generationFailed
|
|
1323
|
+
? "generation_failed_recovery"
|
|
1324
|
+
: "empty_response_recovery",
|
|
1325
|
+
runtimeTurn: turnNumber,
|
|
1326
|
+
pendingToolAcknowledgement:
|
|
1327
|
+
activeHandoff.pendingToolAcknowledgement ?? null,
|
|
1328
|
+
},
|
|
497
1329
|
);
|
|
1330
|
+
activeHandoff = continuationSend.pendingAssistantTurn;
|
|
498
1331
|
await this.emit("model.message_sent", {
|
|
499
1332
|
kind: deadRequest
|
|
500
1333
|
? "dead_request_recovery"
|
|
@@ -505,18 +1338,34 @@ export class AgentRuntime {
|
|
|
505
1338
|
});
|
|
506
1339
|
}
|
|
507
1340
|
}
|
|
508
|
-
const assistantMessageId =
|
|
509
|
-
.
|
|
1341
|
+
const assistantMessageId = activeHandoff.status === "complete"
|
|
1342
|
+
? activeHandoff.assistantMessageId ?? null
|
|
1343
|
+
: await this.adapter.getLastAssistantMessageId?.() ?? null;
|
|
1344
|
+
const assistantTurn = activeHandoff.status === "complete"
|
|
1345
|
+
? activeHandoff.assistantTurn ?? null
|
|
1346
|
+
: await this.adapter.getLastAssistantTurn?.() ?? null;
|
|
1347
|
+
if (activeHandoff.status === "waiting") {
|
|
1348
|
+
activeHandoff = await this.#queueSessionOperation(() => (
|
|
1349
|
+
this.session.completePendingAssistantTurn({
|
|
1350
|
+
handoffId: activeHandoff.handoffId,
|
|
1351
|
+
assistantMessageId,
|
|
1352
|
+
assistantTurn,
|
|
1353
|
+
rawResponse: raw,
|
|
1354
|
+
})
|
|
1355
|
+
));
|
|
1356
|
+
} else {
|
|
1357
|
+
const rawHash = createHash("sha256").update(raw).digest("hex");
|
|
1358
|
+
if (rawHash !== activeHandoff.responseHash) {
|
|
1359
|
+
throw new BrowserAdapterError(
|
|
1360
|
+
"The durable assistant response changed before processing.",
|
|
1361
|
+
{ code: "ASSISTANT_HANDOFF_COLLISION", recoverable: false },
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
510
1365
|
if (awaitingPendingAcknowledgement) {
|
|
511
|
-
await this
|
|
512
|
-
awaitingPendingAcknowledgement = false;
|
|
1366
|
+
await this.#queueSessionUpdate({ pendingToolResult: null });
|
|
513
1367
|
}
|
|
514
|
-
|
|
515
|
-
conversationUrl: await this.adapter.getConversationUrl(),
|
|
516
|
-
// Null is meaningful: retaining an older ID would falsely prove only
|
|
517
|
-
// that stale history had hydrated on the next resume.
|
|
518
|
-
lastAssistantMessageId: assistantMessageId,
|
|
519
|
-
});
|
|
1368
|
+
awaitingPendingAcknowledgement = false;
|
|
520
1369
|
await this.emit("model.message_complete", {
|
|
521
1370
|
turn: turnNumber,
|
|
522
1371
|
raw,
|
|
@@ -524,8 +1373,34 @@ export class AgentRuntime {
|
|
|
524
1373
|
});
|
|
525
1374
|
|
|
526
1375
|
let parsed;
|
|
1376
|
+
let finalMessage;
|
|
1377
|
+
let usedTrailingProse = false;
|
|
527
1378
|
try {
|
|
528
1379
|
parsed = parseAgentResponse(raw);
|
|
1380
|
+
finalMessage = parsed.message;
|
|
1381
|
+
// Some providers put the deliverable after a done=true envelope. Merge
|
|
1382
|
+
// that display text before validating the final answer.
|
|
1383
|
+
if (parsed.done) {
|
|
1384
|
+
const trailing = extractTrailingProse(raw);
|
|
1385
|
+
if (trailing) {
|
|
1386
|
+
finalMessage = [parsed.message.trim(), trailing]
|
|
1387
|
+
.filter(Boolean)
|
|
1388
|
+
.join("\n\n");
|
|
1389
|
+
usedTrailingProse = true;
|
|
1390
|
+
}
|
|
1391
|
+
if (!finalMessage.trim()) {
|
|
1392
|
+
throw new ProtocolError("done=true requires a non-empty final message.");
|
|
1393
|
+
}
|
|
1394
|
+
if (
|
|
1395
|
+
!parsed.toolCall
|
|
1396
|
+
&& /<tool_calls[\s>]|<tool_call[\s>]|<invoke[\s>]/i.test(finalMessage)
|
|
1397
|
+
) {
|
|
1398
|
+
throw new ProtocolError(
|
|
1399
|
+
"Tool calls must use the <tool_call> element, not the <message> text.",
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
// Syntax and semantic errors share one consecutive-retry budget.
|
|
529
1404
|
protocolErrors = 0;
|
|
530
1405
|
} catch (error) {
|
|
531
1406
|
if (!(error instanceof ProtocolError)) {
|
|
@@ -565,12 +1440,21 @@ export class AgentRuntime {
|
|
|
565
1440
|
await this.emit("protocol.plain_answer", {
|
|
566
1441
|
snippet: plainAnswer.slice(0, 200),
|
|
567
1442
|
});
|
|
568
|
-
await this
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
await this
|
|
1443
|
+
await this.#appendHandoffTranscript(
|
|
1444
|
+
activeHandoff,
|
|
1445
|
+
"assistant",
|
|
1446
|
+
assistantMessage(plainAnswer),
|
|
1447
|
+
);
|
|
1448
|
+
await this.#queueSessionOperation(() => (
|
|
1449
|
+
this.session.clearPendingAssistantTurn(
|
|
1450
|
+
activeHandoff.handoffId,
|
|
1451
|
+
{
|
|
1452
|
+
phase: "idle",
|
|
1453
|
+
lastMessage: plainAnswer,
|
|
1454
|
+
pendingToolResult: null,
|
|
1455
|
+
},
|
|
1456
|
+
)
|
|
1457
|
+
));
|
|
574
1458
|
await this.emit("run.completed", {
|
|
575
1459
|
message: plainAnswer,
|
|
576
1460
|
plainAnswer: true,
|
|
@@ -591,79 +1475,39 @@ export class AgentRuntime {
|
|
|
591
1475
|
`Protocol failed ${protocolErrors} consecutive times: ${error.message}`,
|
|
592
1476
|
);
|
|
593
1477
|
}
|
|
594
|
-
await this.sendMessage(serializeProtocolError(error)
|
|
1478
|
+
await this.sendMessage(serializeProtocolError(error), {
|
|
1479
|
+
allowAssistantContinuation: true,
|
|
1480
|
+
outboundKind: "protocol_correction",
|
|
1481
|
+
runtimeTurn: turnNumber + 1,
|
|
1482
|
+
});
|
|
595
1483
|
continue;
|
|
596
1484
|
}
|
|
597
1485
|
|
|
598
|
-
// When the run finishes, some models (GLM especially) put their real
|
|
599
|
-
// deliverable AFTER the envelope: a short done/true stub followed by the
|
|
600
|
-
// full markdown/HTML answer. That trailing prose is pure display content
|
|
601
|
-
// (it cannot trigger a tool), so merge it into the final message instead
|
|
602
|
-
// of dropping it. Non-done turns keep the protocol message untouched.
|
|
603
|
-
let finalMessage = parsed.message;
|
|
604
|
-
let usedTrailingProse = false;
|
|
605
|
-
if (parsed.done) {
|
|
606
|
-
const trailing = extractTrailingProse(raw);
|
|
607
|
-
if (trailing) {
|
|
608
|
-
finalMessage = [parsed.message.trim(), trailing]
|
|
609
|
-
.filter(Boolean)
|
|
610
|
-
.join("\n\n");
|
|
611
|
-
usedTrailingProse = true;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
1486
|
// Record the assistant's turn in the canonical transcript. The raw XML is
|
|
616
1487
|
// the web rendering; the transcript keeps the plain progress message.
|
|
617
1488
|
if (finalMessage?.trim()) {
|
|
618
|
-
await this
|
|
1489
|
+
await this.#appendHandoffTranscript(
|
|
1490
|
+
activeHandoff,
|
|
1491
|
+
"assistant",
|
|
619
1492
|
assistantMessage(finalMessage),
|
|
620
1493
|
);
|
|
621
1494
|
}
|
|
622
1495
|
|
|
623
1496
|
if (parsed.done) {
|
|
624
|
-
if (!finalMessage.trim()) {
|
|
625
|
-
const error = new ProtocolError(
|
|
626
|
-
"done=true requires a non-empty final message.",
|
|
627
|
-
);
|
|
628
|
-
await this.emit("protocol.invalid", { message: error.message });
|
|
629
|
-
await this.sendMessage(serializeProtocolError(error));
|
|
630
|
-
continue;
|
|
631
|
-
}
|
|
632
|
-
// Some models (DeepSeek especially) write their tool request inside
|
|
633
|
-
// the <message> of a done=true envelope instead of a <tool_call>
|
|
634
|
-
// element. Completing there would swallow the tool call and end the
|
|
635
|
-
// run with raw XML as the "answer". Treat it as a format slip: the
|
|
636
|
-
// protocol-error feedback tells the model where tool calls go, and
|
|
637
|
-
// the normal retry limit still applies.
|
|
638
|
-
if (
|
|
639
|
-
!parsed.toolCall
|
|
640
|
-
&& /<tool_calls[\s>]|<tool_call[\s>]|<invoke[\s>]/i.test(finalMessage)
|
|
641
|
-
) {
|
|
642
|
-
const error = new ProtocolError(
|
|
643
|
-
"Tool calls must use the <tool_call> element, not the <message> text.",
|
|
644
|
-
);
|
|
645
|
-
protocolErrors += 1;
|
|
646
|
-
await this.emit("protocol.invalid", {
|
|
647
|
-
message: error.message,
|
|
648
|
-
count: protocolErrors,
|
|
649
|
-
});
|
|
650
|
-
if (protocolErrors >= this.limits.maxProtocolErrors) {
|
|
651
|
-
throw new ProtocolError(
|
|
652
|
-
`Protocol failed ${protocolErrors} consecutive times: ${error.message}`,
|
|
653
|
-
);
|
|
654
|
-
}
|
|
655
|
-
await this.sendMessage(serializeProtocolError(error));
|
|
656
|
-
continue;
|
|
657
|
-
}
|
|
658
1497
|
// done=true completes the run. A request may be answered directly
|
|
659
1498
|
// (no tool call) or after any number of tools; the runtime does not
|
|
660
1499
|
// second-guess whether "enough" work happened — that is the model's
|
|
661
1500
|
// and the user's call, not a keyword heuristic.
|
|
662
|
-
await this
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
1501
|
+
await this.#queueSessionOperation(() => (
|
|
1502
|
+
this.session.clearPendingAssistantTurn(
|
|
1503
|
+
activeHandoff.handoffId,
|
|
1504
|
+
{
|
|
1505
|
+
phase: "idle",
|
|
1506
|
+
lastMessage: finalMessage,
|
|
1507
|
+
pendingToolResult: null,
|
|
1508
|
+
},
|
|
1509
|
+
)
|
|
1510
|
+
));
|
|
667
1511
|
await this.emit("run.completed", {
|
|
668
1512
|
message: finalMessage,
|
|
669
1513
|
usedTrailingProse,
|
|
@@ -692,6 +1536,11 @@ export class AgentRuntime {
|
|
|
692
1536
|
+ "If you need to take action on the user's machine, request one local tool. "
|
|
693
1537
|
+ "If you need information from the user, ask one specific question in <message> "
|
|
694
1538
|
+ "and set <done>true</done> so control returns to the user.",
|
|
1539
|
+
{
|
|
1540
|
+
allowAssistantContinuation: true,
|
|
1541
|
+
outboundKind: "progress_correction",
|
|
1542
|
+
runtimeTurn: turnNumber + 1,
|
|
1543
|
+
},
|
|
695
1544
|
);
|
|
696
1545
|
continue;
|
|
697
1546
|
}
|
|
@@ -734,16 +1583,24 @@ export class AgentRuntime {
|
|
|
734
1583
|
await this.onEvent?.(completionEvent);
|
|
735
1584
|
// Record the rejected call and its error output so the transcript stays
|
|
736
1585
|
// a faithful, replay-free record of what the model attempted.
|
|
737
|
-
await this
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
await this
|
|
1586
|
+
await this.#appendHandoffTranscript(
|
|
1587
|
+
activeHandoff,
|
|
1588
|
+
"function_call",
|
|
1589
|
+
functionCall({
|
|
1590
|
+
name: normalizedCall.name,
|
|
1591
|
+
args: normalizedCall.args,
|
|
1592
|
+
callId: normalizedCall.id,
|
|
1593
|
+
}),
|
|
1594
|
+
);
|
|
1595
|
+
await this.#appendHandoffTranscript(
|
|
1596
|
+
activeHandoff,
|
|
1597
|
+
"function_output",
|
|
1598
|
+
functionCallOutput({
|
|
1599
|
+
callId: result.callId,
|
|
1600
|
+
output: toolResultOutput(result),
|
|
1601
|
+
}),
|
|
1602
|
+
);
|
|
1603
|
+
await this.sendToolResult(result, { runtimeTurn: turnNumber + 1 });
|
|
747
1604
|
awaitingPendingAcknowledgement = true;
|
|
748
1605
|
await this.emit("tool.result_sent", {
|
|
749
1606
|
id: result.callId,
|
|
@@ -757,11 +1614,15 @@ export class AgentRuntime {
|
|
|
757
1614
|
name: preparedCall.name,
|
|
758
1615
|
args: preparedCall.args,
|
|
759
1616
|
});
|
|
760
|
-
await this
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1617
|
+
await this.#appendHandoffTranscript(
|
|
1618
|
+
activeHandoff,
|
|
1619
|
+
"function_call",
|
|
1620
|
+
functionCall({
|
|
1621
|
+
name: preparedCall.name,
|
|
1622
|
+
args: preparedCall.args,
|
|
1623
|
+
callId: preparedCall.id,
|
|
1624
|
+
}),
|
|
1625
|
+
);
|
|
765
1626
|
|
|
766
1627
|
const isReadTool = preparedCall.definition.risk === "read";
|
|
767
1628
|
const sideEffect = isReadTool ? null : identity;
|
|
@@ -933,11 +1794,15 @@ export class AgentRuntime {
|
|
|
933
1794
|
}
|
|
934
1795
|
}
|
|
935
1796
|
|
|
936
|
-
await this
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1797
|
+
await this.#appendHandoffTranscript(
|
|
1798
|
+
activeHandoff,
|
|
1799
|
+
"function_output",
|
|
1800
|
+
functionCallOutput({
|
|
1801
|
+
callId: result.callId,
|
|
1802
|
+
output: toolResultOutput(result),
|
|
1803
|
+
}),
|
|
1804
|
+
);
|
|
1805
|
+
await this.sendToolResult(result, { runtimeTurn: turnNumber + 1 });
|
|
941
1806
|
awaitingPendingAcknowledgement = true;
|
|
942
1807
|
await this.emit("tool.result_sent", {
|
|
943
1808
|
id: result.callId,
|