wtagent 0.2.2 → 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 +4 -4
- package/docs/technical-design.md +18 -11
- package/package.json +6 -6
- package/src/browser/base-web-adapter.js +2085 -202
- package/src/browser/cdp-browser.js +223 -25
- package/src/browser/cdp-state.js +8 -5
- package/src/browser/chatgpt-web-adapter.js +803 -218
- package/src/browser/claude-web-adapter.js +13 -0
- package/src/browser/deepseek-web-adapter.js +0 -106
- 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 +4 -91
- package/src/browser/grok-web-adapter.js +145 -0
- package/src/browser/kimi-web-adapter.js +2 -74
- package/src/browser/provider-registry.js +8 -50
- package/src/cli/i18n.js +949 -0
- package/src/cli/main.js +217 -223
- package/src/cli/prompt-input.js +349 -151
- package/src/cli/render-events.js +45 -35
- package/src/cli/self-update.js +10 -3
- 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 +1065 -237
- package/src/session/agent-session.js +975 -120
- package/src/shared/limits.js +2 -9
- package/src/tools/default-tools.js +1 -1
- package/src/browser/mode-selection.js +0 -214
- package/src/cli/mode-choice.js +0 -25
|
@@ -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,21 +19,21 @@ 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";
|
|
22
|
-
import {
|
|
23
|
-
DEFAULT_LIMITS,
|
|
24
|
-
PRO_MODEL_TURN_TIMEOUT_MS,
|
|
25
|
-
isProMode,
|
|
26
|
-
} from "../shared/limits.js";
|
|
26
|
+
import { DEFAULT_LIMITS } from "../shared/limits.js";
|
|
27
27
|
import { utf8ByteLength } from "../shared/text-budget.js";
|
|
28
28
|
import {
|
|
29
29
|
BrowserAdapterError,
|
|
30
30
|
ProtocolError,
|
|
31
31
|
ToolValidationError,
|
|
32
32
|
} from "../shared/errors.js";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
isConnectionLostError,
|
|
35
|
+
isValidOutboundCorrelationId,
|
|
36
|
+
} from "../browser/base-web-adapter.js";
|
|
34
37
|
import { isUsageLimitNotice } from "../shared/usage-limit.js";
|
|
35
38
|
|
|
36
39
|
const EMPTY_ASSISTANT_CONTINUE_MESSAGE =
|
|
@@ -48,6 +51,11 @@ const GENERATION_FAILED_CONTINUE_MESSAGE =
|
|
|
48
51
|
+ "Retry the immediately preceding task from the existing conversation context. Do not "
|
|
49
52
|
+ "repeat any local tool operation whose result is already present. Reply using the "
|
|
50
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
|
+
);
|
|
51
59
|
|
|
52
60
|
function canonicalize(value) {
|
|
53
61
|
if (Array.isArray(value)) {
|
|
@@ -148,6 +156,7 @@ export class AgentRuntime {
|
|
|
148
156
|
session,
|
|
149
157
|
approval,
|
|
150
158
|
onEvent,
|
|
159
|
+
postAuthSetup = null,
|
|
151
160
|
limits = DEFAULT_LIMITS,
|
|
152
161
|
}) {
|
|
153
162
|
this.adapter = adapter;
|
|
@@ -156,7 +165,13 @@ export class AgentRuntime {
|
|
|
156
165
|
this.session = session;
|
|
157
166
|
this.approval = approval;
|
|
158
167
|
this.onEvent = onEvent;
|
|
168
|
+
this.postAuthSetup = postAuthSetup;
|
|
159
169
|
this.limits = limits;
|
|
170
|
+
this.conversationIdentityQueue = Promise.resolve();
|
|
171
|
+
this.deferConversationIdentityPersistence = false;
|
|
172
|
+
this.adapter.setConversationIdentityListener?.((identity) =>
|
|
173
|
+
this.#queueConversationIdentity(identity)
|
|
174
|
+
);
|
|
160
175
|
}
|
|
161
176
|
|
|
162
177
|
async emit(type, payload = {}) {
|
|
@@ -165,60 +180,409 @@ export class AgentRuntime {
|
|
|
165
180
|
return event;
|
|
166
181
|
}
|
|
167
182
|
|
|
168
|
-
async sendMessage(text, {
|
|
169
|
-
|
|
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 });
|
|
170
210
|
try {
|
|
171
|
-
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;
|
|
172
252
|
} finally {
|
|
173
|
-
|
|
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)
|
|
174
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;
|
|
175
349
|
if (
|
|
176
|
-
conversationUrl
|
|
177
|
-
&&
|
|
350
|
+
conversationUrl === current.conversationUrl
|
|
351
|
+
&& conversationTargetId === current.conversationTargetId
|
|
178
352
|
) {
|
|
179
|
-
|
|
353
|
+
return structuredClone(current);
|
|
180
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;
|
|
181
406
|
}
|
|
182
407
|
}
|
|
183
408
|
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// the
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
}) {
|
|
190
419
|
try {
|
|
191
|
-
await this.adapter.sendMessage(message, {
|
|
420
|
+
return await this.adapter.sendMessage(message, {
|
|
421
|
+
files,
|
|
422
|
+
maxBytes,
|
|
423
|
+
outboundId,
|
|
424
|
+
allowAssistantContinuation,
|
|
425
|
+
});
|
|
192
426
|
} catch (error) {
|
|
193
427
|
if (isConnectionLostError(error)) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
});
|
|
201
456
|
}
|
|
202
457
|
throw error;
|
|
203
458
|
}
|
|
204
459
|
}
|
|
205
460
|
|
|
206
|
-
// Reconnects to the still-alive Chrome and
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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)
|
|
216
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 };
|
|
217
580
|
}
|
|
218
581
|
|
|
219
582
|
buildToolResultMessage(result, { suffix = "" } = {}) {
|
|
220
583
|
const limit = this.limits.maxBrowserToolResultBytes;
|
|
221
|
-
const nonResultBytes = utf8ByteLength(appendSystemReminder(suffix))
|
|
584
|
+
const nonResultBytes = utf8ByteLength(appendSystemReminder(suffix))
|
|
585
|
+
+ OUTBOUND_CORRELATION_RESERVE_BYTES;
|
|
222
586
|
const resultBudget = limit - nonResultBytes;
|
|
223
587
|
if (resultBudget < 512) {
|
|
224
588
|
throw new RangeError(
|
|
@@ -228,27 +592,280 @@ export class AgentRuntime {
|
|
|
228
592
|
return `${serializeToolResult(result, { maxBytes: resultBudget })}${suffix}`;
|
|
229
593
|
}
|
|
230
594
|
|
|
231
|
-
async sendToolResult(result, {
|
|
232
|
-
|
|
233
|
-
|
|
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}`,
|
|
234
620
|
});
|
|
235
621
|
}
|
|
236
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,
|
|
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;
|
|
812
|
+
}
|
|
813
|
+
|
|
237
814
|
async run({
|
|
238
815
|
resume = false,
|
|
239
816
|
instruction = null,
|
|
240
817
|
files = [],
|
|
241
818
|
inPlaceRecovery = false,
|
|
242
|
-
mode = null,
|
|
243
819
|
} = {}) {
|
|
244
820
|
const {
|
|
245
821
|
task,
|
|
246
822
|
projectRoot,
|
|
247
|
-
mode: sessionMode,
|
|
248
823
|
} = this.session.state;
|
|
249
824
|
const previousConversationUrl = this.session.state.conversationUrl;
|
|
250
|
-
|
|
251
|
-
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
|
+
}
|
|
252
869
|
|
|
253
870
|
await this.session.update({
|
|
254
871
|
phase: "initializing",
|
|
@@ -257,10 +874,49 @@ export class AgentRuntime {
|
|
|
257
874
|
});
|
|
258
875
|
await this.emit("runtime.initializing");
|
|
259
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 {
|
|
260
911
|
// On resume, prefer an existing tab already showing the conversation so
|
|
261
912
|
// repeated resumes reuse the same tab instead of piling up new ones.
|
|
262
913
|
await this.adapter.launch(
|
|
263
914
|
resume ? this.session.state.conversationUrl : null,
|
|
915
|
+
{
|
|
916
|
+
preferredTargetId: resume
|
|
917
|
+
? this.session.state.conversationTargetId ?? null
|
|
918
|
+
: null,
|
|
919
|
+
},
|
|
264
920
|
);
|
|
265
921
|
await this.emit("browser.started", {
|
|
266
922
|
profileDir: this.adapter.profileDir,
|
|
@@ -292,80 +948,150 @@ export class AgentRuntime {
|
|
|
292
948
|
await this.emit("browser.authenticated");
|
|
293
949
|
}
|
|
294
950
|
|
|
295
|
-
await this.adapter.startConversation(
|
|
951
|
+
let restoration = await this.adapter.startConversation(
|
|
296
952
|
resume ? previousConversationUrl : null,
|
|
297
953
|
{
|
|
298
954
|
expectedAssistantMessageId: resume
|
|
299
955
|
? this.session.state.lastAssistantMessageId
|
|
300
956
|
: null,
|
|
957
|
+
expectedUserMessageId: resume
|
|
958
|
+
? this.session.state.lastUserMessageId
|
|
959
|
+
: null,
|
|
301
960
|
},
|
|
302
961
|
);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
+
);
|
|
334
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;
|
|
1032
|
+
|
|
1033
|
+
// Optional setup after authentication and the target conversation are
|
|
1034
|
+
// ready, but before the first message is sent. Interactive CLI runs use
|
|
1035
|
+
// this to let the user choose a model directly on the provider website.
|
|
1036
|
+
// WTAgent never names, selects, or overrides a provider-specific model.
|
|
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) {
|
|
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
|
+
});
|
|
335
1063
|
}
|
|
336
|
-
// ChatGPT Pro can think considerably longer before the first token;
|
|
337
|
-
// every other provider/mode uses the default. An explicit
|
|
338
|
-
// --model-turn-timeout-ms always wins (resolveLimits marks it). The
|
|
339
|
-
// active mode may differ from the requested one (Pro limited, fallback),
|
|
340
|
-
// so prefer the mode the conversation is actually on.
|
|
341
|
-
const modelTurnTimeoutMs = !this.limits.modelTurnTimeoutExplicit
|
|
342
|
-
&& (isProMode(activeMode) || isProMode(requestedMode))
|
|
343
|
-
? PRO_MODEL_TURN_TIMEOUT_MS
|
|
344
|
-
: this.limits.modelTurnTimeoutMs;
|
|
345
1064
|
|
|
346
1065
|
await this.session.update({
|
|
347
1066
|
phase: "running",
|
|
348
|
-
|
|
349
|
-
activeMode,
|
|
1067
|
+
activeMode: null,
|
|
350
1068
|
});
|
|
351
1069
|
await this.emit("conversation.started", {
|
|
352
1070
|
url: this.session.state.conversationUrl,
|
|
353
|
-
mode: activeMode,
|
|
354
|
-
requestedMode,
|
|
355
1071
|
});
|
|
356
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
|
+
}
|
|
357
1088
|
let initialMessage;
|
|
358
1089
|
let initialKind;
|
|
359
1090
|
// The web transport gets `.web` (XML/marked text). The portable rollout
|
|
360
|
-
// records only
|
|
1091
|
+
// records only confirmed user messages; WTAgent scaffolding stays transport-only.
|
|
361
1092
|
let initialTranscript = [];
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
name: file.name ?? null,
|
|
365
|
-
path: file.path ?? null,
|
|
366
|
-
}));
|
|
367
|
-
const messageOptions = attachments.length > 0 ? { attachments } : {};
|
|
368
|
-
if (resume && pendingToolResult && !inPlaceRecovery) {
|
|
1093
|
+
if (!legacyAssistantRecoveryAtStart) {
|
|
1094
|
+
if (resumedExistingConversation && pendingToolResult && !inPlaceRecovery) {
|
|
369
1095
|
let suffix = "";
|
|
370
1096
|
if (instruction?.trim()) {
|
|
371
1097
|
suffix = `\n<resume_instruction>${cdata(instruction)}</resume_instruction>`;
|
|
@@ -373,7 +1099,7 @@ export class AgentRuntime {
|
|
|
373
1099
|
}
|
|
374
1100
|
initialMessage = this.buildToolResultMessage(pendingToolResult, { suffix });
|
|
375
1101
|
initialKind = "pending_tool_result";
|
|
376
|
-
} else if (
|
|
1102
|
+
} else if (resumedExistingConversation && instruction?.trim()) {
|
|
377
1103
|
// The live web conversation already contains the bootstrap protocol
|
|
378
1104
|
// and tool catalog. A normal follow-up should be the user's message, not
|
|
379
1105
|
// another several-thousand-character protocol bootstrap. sendMessage()
|
|
@@ -381,7 +1107,7 @@ export class AgentRuntime {
|
|
|
381
1107
|
initialMessage = instruction.trim();
|
|
382
1108
|
initialTranscript = [userMessage(instruction.trim(), messageOptions)];
|
|
383
1109
|
initialKind = "follow_up";
|
|
384
|
-
} else if (
|
|
1110
|
+
} else if (resumedExistingConversation && inPlaceRecovery) {
|
|
385
1111
|
// The original request/tool result is already visible in this live web
|
|
386
1112
|
// conversation. Ask the provider to continue without duplicating transport
|
|
387
1113
|
// payloads, attachments, or canonical transcript entries.
|
|
@@ -389,13 +1115,21 @@ export class AgentRuntime {
|
|
|
389
1115
|
initialKind = "empty_response_recovery";
|
|
390
1116
|
} else if (resume) {
|
|
391
1117
|
const prompt = buildResumePrompt({
|
|
392
|
-
instruction
|
|
1118
|
+
instruction: rebuildingFreshConversation
|
|
1119
|
+
? effectiveInstruction
|
|
1120
|
+
: instruction,
|
|
393
1121
|
state: this.session.state,
|
|
394
1122
|
tools: this.registry.list(),
|
|
395
1123
|
});
|
|
396
1124
|
initialMessage = prompt.web;
|
|
397
|
-
|
|
398
|
-
|
|
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";
|
|
399
1133
|
} else {
|
|
400
1134
|
const prompt = buildBootstrapPrompt({
|
|
401
1135
|
task,
|
|
@@ -403,53 +1137,88 @@ export class AgentRuntime {
|
|
|
403
1137
|
tools: this.registry.list(),
|
|
404
1138
|
});
|
|
405
1139
|
initialMessage = prompt.web;
|
|
406
|
-
|
|
1140
|
+
// The opening item was persisted before browser setup above.
|
|
1141
|
+
initialTranscript = [];
|
|
407
1142
|
initialKind = "bootstrap";
|
|
408
1143
|
}
|
|
409
|
-
|
|
410
|
-
for (const item of initialTranscript) {
|
|
411
|
-
await this.session.appendTranscriptItem(item);
|
|
412
1144
|
}
|
|
413
|
-
await this.sendMessage(initialMessage, {
|
|
414
|
-
files,
|
|
415
|
-
maxBytes: initialKind === "pending_tool_result"
|
|
416
|
-
? this.limits.maxBrowserToolResultBytes
|
|
417
|
-
: null,
|
|
418
|
-
});
|
|
419
|
-
let awaitingPendingAcknowledgement = Boolean(pendingToolResult);
|
|
420
|
-
let replayGuard = pendingToolResult?.operationSignature
|
|
421
|
-
? {
|
|
422
|
-
signature: pendingToolResult.operationSignature,
|
|
423
|
-
result: pendingToolResult,
|
|
424
|
-
}
|
|
425
|
-
: null;
|
|
426
|
-
await this.emit("model.message_sent", { kind: initialKind });
|
|
427
1145
|
|
|
428
|
-
|
|
429
|
-
|
|
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
|
+
}
|
|
430
1177
|
|
|
431
1178
|
for (let step = 1; ; step += 1) {
|
|
432
|
-
|
|
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;
|
|
433
1189
|
await this.session.update({ turn: turnNumber, phase: "waiting_model" });
|
|
434
1190
|
let raw;
|
|
435
1191
|
let emptyAssistantRetries = 0;
|
|
436
1192
|
let connectionRetries = 0;
|
|
437
1193
|
for (;;) {
|
|
438
1194
|
try {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
+
},
|
|
450
1212
|
});
|
|
451
|
-
}
|
|
452
|
-
|
|
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
|
+
}
|
|
453
1222
|
break;
|
|
454
1223
|
} catch (error) {
|
|
455
1224
|
if (isConnectionLostError(error)) {
|
|
@@ -457,9 +1226,25 @@ export class AgentRuntime {
|
|
|
457
1226
|
throw error;
|
|
458
1227
|
}
|
|
459
1228
|
connectionRetries += 1;
|
|
460
|
-
|
|
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
|
+
}
|
|
461
1246
|
// The message was already sent before the connection died; resume
|
|
462
|
-
// waiting for the reply on the restored
|
|
1247
|
+
// waiting for the reply on the restored exact conversation boundary.
|
|
463
1248
|
continue;
|
|
464
1249
|
}
|
|
465
1250
|
|
|
@@ -485,7 +1270,6 @@ export class AgentRuntime {
|
|
|
485
1270
|
const emptyAssistantMessageId = await this.adapter
|
|
486
1271
|
.getLastAssistantMessageId?.() ?? null;
|
|
487
1272
|
await this.session.update({
|
|
488
|
-
conversationUrl: await this.adapter.getConversationUrl(),
|
|
489
1273
|
lastAssistantMessageId: emptyAssistantMessageId
|
|
490
1274
|
?? this.session.state.lastAssistantMessageId,
|
|
491
1275
|
});
|
|
@@ -525,13 +1309,25 @@ export class AgentRuntime {
|
|
|
525
1309
|
// Do not resend the original request or tool result: both are already
|
|
526
1310
|
// present in ChatGPT's conversation. This transport-only continuation
|
|
527
1311
|
// also cannot re-execute a local tool by itself.
|
|
528
|
-
await this.sendMessage(
|
|
1312
|
+
const continuationSend = await this.sendMessage(
|
|
529
1313
|
deadRequest
|
|
530
1314
|
? DEAD_REQUEST_CONTINUE_MESSAGE
|
|
531
1315
|
: generationFailed
|
|
532
1316
|
? GENERATION_FAILED_CONTINUE_MESSAGE
|
|
533
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
|
+
},
|
|
534
1329
|
);
|
|
1330
|
+
activeHandoff = continuationSend.pendingAssistantTurn;
|
|
535
1331
|
await this.emit("model.message_sent", {
|
|
536
1332
|
kind: deadRequest
|
|
537
1333
|
? "dead_request_recovery"
|
|
@@ -542,18 +1338,34 @@ export class AgentRuntime {
|
|
|
542
1338
|
});
|
|
543
1339
|
}
|
|
544
1340
|
}
|
|
545
|
-
const assistantMessageId =
|
|
546
|
-
.
|
|
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
|
+
}
|
|
547
1365
|
if (awaitingPendingAcknowledgement) {
|
|
548
|
-
await this
|
|
549
|
-
awaitingPendingAcknowledgement = false;
|
|
1366
|
+
await this.#queueSessionUpdate({ pendingToolResult: null });
|
|
550
1367
|
}
|
|
551
|
-
|
|
552
|
-
conversationUrl: await this.adapter.getConversationUrl(),
|
|
553
|
-
// Null is meaningful: retaining an older ID would falsely prove only
|
|
554
|
-
// that stale history had hydrated on the next resume.
|
|
555
|
-
lastAssistantMessageId: assistantMessageId,
|
|
556
|
-
});
|
|
1368
|
+
awaitingPendingAcknowledgement = false;
|
|
557
1369
|
await this.emit("model.message_complete", {
|
|
558
1370
|
turn: turnNumber,
|
|
559
1371
|
raw,
|
|
@@ -561,8 +1373,34 @@ export class AgentRuntime {
|
|
|
561
1373
|
});
|
|
562
1374
|
|
|
563
1375
|
let parsed;
|
|
1376
|
+
let finalMessage;
|
|
1377
|
+
let usedTrailingProse = false;
|
|
564
1378
|
try {
|
|
565
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.
|
|
566
1404
|
protocolErrors = 0;
|
|
567
1405
|
} catch (error) {
|
|
568
1406
|
if (!(error instanceof ProtocolError)) {
|
|
@@ -602,12 +1440,21 @@ export class AgentRuntime {
|
|
|
602
1440
|
await this.emit("protocol.plain_answer", {
|
|
603
1441
|
snippet: plainAnswer.slice(0, 200),
|
|
604
1442
|
});
|
|
605
|
-
await this
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
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
|
+
));
|
|
611
1458
|
await this.emit("run.completed", {
|
|
612
1459
|
message: plainAnswer,
|
|
613
1460
|
plainAnswer: true,
|
|
@@ -628,79 +1475,39 @@ export class AgentRuntime {
|
|
|
628
1475
|
`Protocol failed ${protocolErrors} consecutive times: ${error.message}`,
|
|
629
1476
|
);
|
|
630
1477
|
}
|
|
631
|
-
await this.sendMessage(serializeProtocolError(error)
|
|
1478
|
+
await this.sendMessage(serializeProtocolError(error), {
|
|
1479
|
+
allowAssistantContinuation: true,
|
|
1480
|
+
outboundKind: "protocol_correction",
|
|
1481
|
+
runtimeTurn: turnNumber + 1,
|
|
1482
|
+
});
|
|
632
1483
|
continue;
|
|
633
1484
|
}
|
|
634
1485
|
|
|
635
|
-
// When the run finishes, some models (GLM especially) put their real
|
|
636
|
-
// deliverable AFTER the envelope: a short done/true stub followed by the
|
|
637
|
-
// full markdown/HTML answer. That trailing prose is pure display content
|
|
638
|
-
// (it cannot trigger a tool), so merge it into the final message instead
|
|
639
|
-
// of dropping it. Non-done turns keep the protocol message untouched.
|
|
640
|
-
let finalMessage = parsed.message;
|
|
641
|
-
let usedTrailingProse = false;
|
|
642
|
-
if (parsed.done) {
|
|
643
|
-
const trailing = extractTrailingProse(raw);
|
|
644
|
-
if (trailing) {
|
|
645
|
-
finalMessage = [parsed.message.trim(), trailing]
|
|
646
|
-
.filter(Boolean)
|
|
647
|
-
.join("\n\n");
|
|
648
|
-
usedTrailingProse = true;
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
|
|
652
1486
|
// Record the assistant's turn in the canonical transcript. The raw XML is
|
|
653
1487
|
// the web rendering; the transcript keeps the plain progress message.
|
|
654
1488
|
if (finalMessage?.trim()) {
|
|
655
|
-
await this
|
|
1489
|
+
await this.#appendHandoffTranscript(
|
|
1490
|
+
activeHandoff,
|
|
1491
|
+
"assistant",
|
|
656
1492
|
assistantMessage(finalMessage),
|
|
657
1493
|
);
|
|
658
1494
|
}
|
|
659
1495
|
|
|
660
1496
|
if (parsed.done) {
|
|
661
|
-
if (!finalMessage.trim()) {
|
|
662
|
-
const error = new ProtocolError(
|
|
663
|
-
"done=true requires a non-empty final message.",
|
|
664
|
-
);
|
|
665
|
-
await this.emit("protocol.invalid", { message: error.message });
|
|
666
|
-
await this.sendMessage(serializeProtocolError(error));
|
|
667
|
-
continue;
|
|
668
|
-
}
|
|
669
|
-
// Some models (DeepSeek especially) write their tool request inside
|
|
670
|
-
// the <message> of a done=true envelope instead of a <tool_call>
|
|
671
|
-
// element. Completing there would swallow the tool call and end the
|
|
672
|
-
// run with raw XML as the "answer". Treat it as a format slip: the
|
|
673
|
-
// protocol-error feedback tells the model where tool calls go, and
|
|
674
|
-
// the normal retry limit still applies.
|
|
675
|
-
if (
|
|
676
|
-
!parsed.toolCall
|
|
677
|
-
&& /<tool_calls[\s>]|<tool_call[\s>]|<invoke[\s>]/i.test(finalMessage)
|
|
678
|
-
) {
|
|
679
|
-
const error = new ProtocolError(
|
|
680
|
-
"Tool calls must use the <tool_call> element, not the <message> text.",
|
|
681
|
-
);
|
|
682
|
-
protocolErrors += 1;
|
|
683
|
-
await this.emit("protocol.invalid", {
|
|
684
|
-
message: error.message,
|
|
685
|
-
count: protocolErrors,
|
|
686
|
-
});
|
|
687
|
-
if (protocolErrors >= this.limits.maxProtocolErrors) {
|
|
688
|
-
throw new ProtocolError(
|
|
689
|
-
`Protocol failed ${protocolErrors} consecutive times: ${error.message}`,
|
|
690
|
-
);
|
|
691
|
-
}
|
|
692
|
-
await this.sendMessage(serializeProtocolError(error));
|
|
693
|
-
continue;
|
|
694
|
-
}
|
|
695
1497
|
// done=true completes the run. A request may be answered directly
|
|
696
1498
|
// (no tool call) or after any number of tools; the runtime does not
|
|
697
1499
|
// second-guess whether "enough" work happened — that is the model's
|
|
698
1500
|
// and the user's call, not a keyword heuristic.
|
|
699
|
-
await this
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1501
|
+
await this.#queueSessionOperation(() => (
|
|
1502
|
+
this.session.clearPendingAssistantTurn(
|
|
1503
|
+
activeHandoff.handoffId,
|
|
1504
|
+
{
|
|
1505
|
+
phase: "idle",
|
|
1506
|
+
lastMessage: finalMessage,
|
|
1507
|
+
pendingToolResult: null,
|
|
1508
|
+
},
|
|
1509
|
+
)
|
|
1510
|
+
));
|
|
704
1511
|
await this.emit("run.completed", {
|
|
705
1512
|
message: finalMessage,
|
|
706
1513
|
usedTrailingProse,
|
|
@@ -729,6 +1536,11 @@ export class AgentRuntime {
|
|
|
729
1536
|
+ "If you need to take action on the user's machine, request one local tool. "
|
|
730
1537
|
+ "If you need information from the user, ask one specific question in <message> "
|
|
731
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
|
+
},
|
|
732
1544
|
);
|
|
733
1545
|
continue;
|
|
734
1546
|
}
|
|
@@ -771,16 +1583,24 @@ export class AgentRuntime {
|
|
|
771
1583
|
await this.onEvent?.(completionEvent);
|
|
772
1584
|
// Record the rejected call and its error output so the transcript stays
|
|
773
1585
|
// a faithful, replay-free record of what the model attempted.
|
|
774
|
-
await this
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
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 });
|
|
784
1604
|
awaitingPendingAcknowledgement = true;
|
|
785
1605
|
await this.emit("tool.result_sent", {
|
|
786
1606
|
id: result.callId,
|
|
@@ -794,11 +1614,15 @@ export class AgentRuntime {
|
|
|
794
1614
|
name: preparedCall.name,
|
|
795
1615
|
args: preparedCall.args,
|
|
796
1616
|
});
|
|
797
|
-
await this
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
+
);
|
|
802
1626
|
|
|
803
1627
|
const isReadTool = preparedCall.definition.risk === "read";
|
|
804
1628
|
const sideEffect = isReadTool ? null : identity;
|
|
@@ -970,11 +1794,15 @@ export class AgentRuntime {
|
|
|
970
1794
|
}
|
|
971
1795
|
}
|
|
972
1796
|
|
|
973
|
-
await this
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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 });
|
|
978
1806
|
awaitingPendingAcknowledgement = true;
|
|
979
1807
|
await this.emit("tool.result_sent", {
|
|
980
1808
|
id: result.callId,
|