@ai-sdk/harness 1.0.117 → 1.0.119
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/CHANGELOG.md +22 -0
- package/dist/agent/index.d.ts +25 -3
- package/dist/agent/index.js +130 -41
- package/dist/agent/index.js.map +1 -1
- package/dist/utils/index.d.ts +19 -3
- package/dist/utils/index.js +220 -53
- package/dist/utils/index.js.map +1 -1
- package/package.json +3 -3
- package/src/agent/harness-agent-session.ts +15 -0
- package/src/agent/harness-agent-settings.ts +17 -4
- package/src/agent/harness-agent.ts +25 -1
- package/src/agent/internal/get-own.ts +8 -0
- package/src/agent/internal/run-prompt.ts +31 -3
- package/src/agent/internal/strip-work-dir.ts +91 -20
- package/src/utils/index.ts +2 -0
- package/src/utils/sandbox-channel.ts +220 -57
- package/src/utils/sleep.ts +30 -0
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type FlexibleSchema,
|
|
5
5
|
} from '@ai-sdk/provider-utils';
|
|
6
6
|
import type { WebSocket } from 'ws';
|
|
7
|
+
import { sleep } from './sleep';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Diagnostic event surfaced by {@link SandboxChannel} during its connection
|
|
@@ -22,7 +23,10 @@ export type SandboxChannelDebugEvent =
|
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
export interface SandboxChannelReconnectOptions {
|
|
25
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Give up reconnecting after this many milliseconds, including connection
|
|
28
|
+
* establishment and backoff delays. Default 30_000.
|
|
29
|
+
*/
|
|
26
30
|
readonly maxElapsedMs?: number;
|
|
27
31
|
/** First backoff delay. Default 50. */
|
|
28
32
|
readonly initialDelayMs?: number;
|
|
@@ -30,6 +34,10 @@ export interface SandboxChannelReconnectOptions {
|
|
|
30
34
|
readonly maxDelayMs?: number;
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
export interface SandboxChannelConnectOptions {
|
|
38
|
+
readonly abortSignal: AbortSignal;
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
export interface SandboxChannelOptions<TOut> {
|
|
34
42
|
/**
|
|
35
43
|
* Open a fresh WebSocket to the bridge and resolve once it is ready to carry
|
|
@@ -38,7 +46,7 @@ export interface SandboxChannelOptions<TOut> {
|
|
|
38
46
|
* every transient reconnect. Must reject if the connection cannot be
|
|
39
47
|
* established.
|
|
40
48
|
*/
|
|
41
|
-
connect: () => Promise<WebSocket>;
|
|
49
|
+
connect: (options: SandboxChannelConnectOptions) => Promise<WebSocket>;
|
|
42
50
|
|
|
43
51
|
/** Schema validating inbound (bridge → host) frames. */
|
|
44
52
|
outboundSchema: FlexibleSchema<TOut>;
|
|
@@ -99,12 +107,68 @@ export function pinSandboxChannelEventCheckpoint(
|
|
|
99
107
|
)[sandboxChannelEventCheckpointSymbol]?.pin();
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
function getAbortReason({
|
|
111
|
+
abortSignal,
|
|
112
|
+
}: {
|
|
113
|
+
abortSignal: AbortSignal;
|
|
114
|
+
}): unknown {
|
|
115
|
+
return abortSignal.reason ?? new Error('SandboxChannel connection aborted');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function closeWebSocket({ ws }: { ws: WebSocket }): void {
|
|
119
|
+
try {
|
|
120
|
+
const terminable = ws as WebSocket & { terminate?: () => void };
|
|
121
|
+
if (terminable.terminate != null) {
|
|
122
|
+
terminable.terminate();
|
|
123
|
+
} else {
|
|
124
|
+
ws.close();
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
try {
|
|
128
|
+
ws.close();
|
|
129
|
+
} catch {
|
|
130
|
+
// best-effort
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function awaitWebSocketConnection({
|
|
136
|
+
connection,
|
|
137
|
+
abortSignal,
|
|
138
|
+
}: {
|
|
139
|
+
connection: Promise<WebSocket>;
|
|
140
|
+
abortSignal: AbortSignal;
|
|
141
|
+
}): Promise<WebSocket> {
|
|
142
|
+
if (abortSignal.aborted) {
|
|
143
|
+
void connection.then(ws => closeWebSocket({ ws })).catch(() => {});
|
|
144
|
+
throw getAbortReason({ abortSignal });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let onAbort: (() => void) | undefined;
|
|
148
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
149
|
+
onAbort = () => reject(getAbortReason({ abortSignal }));
|
|
150
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
106
151
|
});
|
|
107
152
|
|
|
153
|
+
try {
|
|
154
|
+
const ws = await Promise.race([connection, aborted]);
|
|
155
|
+
if (abortSignal.aborted) {
|
|
156
|
+
closeWebSocket({ ws });
|
|
157
|
+
throw getAbortReason({ abortSignal });
|
|
158
|
+
}
|
|
159
|
+
return ws;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (abortSignal.aborted) {
|
|
162
|
+
void connection.then(ws => closeWebSocket({ ws })).catch(() => {});
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
} finally {
|
|
166
|
+
if (onAbort != null) {
|
|
167
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
108
172
|
/**
|
|
109
173
|
* Host-side typed wrapper around the bridge WebSocket connection.
|
|
110
174
|
*
|
|
@@ -135,7 +199,9 @@ export class SandboxChannel<
|
|
|
135
199
|
>();
|
|
136
200
|
private readonly onReconnectHandlers = new Set<() => void>();
|
|
137
201
|
|
|
138
|
-
private readonly connectThunk: (
|
|
202
|
+
private readonly connectThunk: (
|
|
203
|
+
options: SandboxChannelConnectOptions,
|
|
204
|
+
) => Promise<WebSocket>;
|
|
139
205
|
private readonly outboundSchema: FlexibleSchema<TOut>;
|
|
140
206
|
private readonly onDebug:
|
|
141
207
|
| ((event: SandboxChannelDebugEvent) => void)
|
|
@@ -169,6 +235,8 @@ export class SandboxChannel<
|
|
|
169
235
|
private _lastSeenEventId = 0;
|
|
170
236
|
private readonly pendingSends: string[] = [];
|
|
171
237
|
private dispatchChain: Promise<void> = Promise.resolve();
|
|
238
|
+
private activeConnectAbortController: AbortController | undefined;
|
|
239
|
+
private reconnectAbortController: AbortController | undefined;
|
|
172
240
|
|
|
173
241
|
constructor(options: SandboxChannelOptions<TOut>) {
|
|
174
242
|
this.connectThunk = options.connect;
|
|
@@ -206,17 +274,27 @@ export class SandboxChannel<
|
|
|
206
274
|
if (this.terminal) {
|
|
207
275
|
throw new Error('SandboxChannel: cannot open a closed channel.');
|
|
208
276
|
}
|
|
209
|
-
const
|
|
210
|
-
this.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
277
|
+
const abortController = new AbortController();
|
|
278
|
+
this.activeConnectAbortController = abortController;
|
|
279
|
+
try {
|
|
280
|
+
const ws = await this.connect({
|
|
281
|
+
abortSignal: abortController.signal,
|
|
282
|
+
});
|
|
283
|
+
this.wire(ws);
|
|
284
|
+
this.ws = ws;
|
|
285
|
+
this.connected = true;
|
|
286
|
+
if (opts?.resume) {
|
|
287
|
+
this.rawSend(
|
|
288
|
+
JSON.stringify({
|
|
289
|
+
type: 'resume',
|
|
290
|
+
lastSeenEventId: this._lastSeenEventId,
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
} finally {
|
|
295
|
+
if (this.activeConnectAbortController === abortController) {
|
|
296
|
+
this.activeConnectAbortController = undefined;
|
|
297
|
+
}
|
|
220
298
|
}
|
|
221
299
|
}
|
|
222
300
|
|
|
@@ -272,11 +350,13 @@ export class SandboxChannel<
|
|
|
272
350
|
*/
|
|
273
351
|
beginClose(): void {
|
|
274
352
|
this.closing = true;
|
|
353
|
+
this.abortActiveConnect();
|
|
275
354
|
}
|
|
276
355
|
|
|
277
356
|
close(): void {
|
|
278
357
|
if (this.terminal) return;
|
|
279
358
|
this.closing = true;
|
|
359
|
+
this.abortActiveConnect();
|
|
280
360
|
try {
|
|
281
361
|
this.ws?.close();
|
|
282
362
|
} catch {
|
|
@@ -310,6 +390,7 @@ export class SandboxChannel<
|
|
|
310
390
|
// suppress reconnect so the socket close finalises.
|
|
311
391
|
this.suspended = true;
|
|
312
392
|
this.closing = true;
|
|
393
|
+
this.abortActiveConnect();
|
|
313
394
|
this.onClose(() =>
|
|
314
395
|
resolve(pinnedSuspensionCursor ?? this._lastSeenEventId),
|
|
315
396
|
);
|
|
@@ -362,62 +443,143 @@ export class SandboxChannel<
|
|
|
362
443
|
|
|
363
444
|
private async reconnectLoop(): Promise<void> {
|
|
364
445
|
if (this.terminal || this.closing) return;
|
|
365
|
-
const
|
|
446
|
+
const deadline = Date.now() + this.maxElapsedMs;
|
|
366
447
|
let attempt = 0;
|
|
367
448
|
let delay = this.initialDelayMs;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
try {
|
|
379
|
-
ws.close();
|
|
380
|
-
} catch {
|
|
381
|
-
// best-effort
|
|
382
|
-
}
|
|
449
|
+
const reconnectAbortController = new AbortController();
|
|
450
|
+
this.reconnectAbortController = reconnectAbortController;
|
|
451
|
+
try {
|
|
452
|
+
while (!this.terminal && !this.closing) {
|
|
453
|
+
const remaining = deadline - Date.now();
|
|
454
|
+
if (remaining <= 0) {
|
|
455
|
+
this.failReconnect({
|
|
456
|
+
attempts: attempt,
|
|
457
|
+
cause: new Error('Reconnect deadline expired'),
|
|
458
|
+
});
|
|
383
459
|
return;
|
|
384
460
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
this.connected = true;
|
|
388
|
-
// Ask the bridge to replay everything we have not seen, then flush any
|
|
389
|
-
// host → bridge frames produced while we were disconnected.
|
|
390
|
-
this.rawSend(
|
|
391
|
-
JSON.stringify({
|
|
392
|
-
type: 'resume',
|
|
393
|
-
lastSeenEventId: this._lastSeenEventId,
|
|
394
|
-
}),
|
|
395
|
-
);
|
|
396
|
-
this.flushPending();
|
|
397
|
-
for (const handler of this.onReconnectHandlers) handler();
|
|
461
|
+
|
|
462
|
+
attempt++;
|
|
398
463
|
this.onDebug?.({
|
|
399
|
-
event: '
|
|
464
|
+
event: 'reconnect-attempt',
|
|
400
465
|
attempt,
|
|
401
466
|
lastSeenEventId: this._lastSeenEventId,
|
|
402
467
|
});
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
468
|
+
const abortController = new AbortController();
|
|
469
|
+
this.activeConnectAbortController = abortController;
|
|
470
|
+
const abortConnect = () =>
|
|
471
|
+
abortController.abort(reconnectAbortController.signal.reason);
|
|
472
|
+
reconnectAbortController.signal.addEventListener(
|
|
473
|
+
'abort',
|
|
474
|
+
abortConnect,
|
|
475
|
+
{
|
|
476
|
+
once: true,
|
|
477
|
+
},
|
|
478
|
+
);
|
|
479
|
+
const timeout = setTimeout(() => {
|
|
480
|
+
abortController.abort(new Error('Reconnect deadline expired'));
|
|
481
|
+
}, remaining);
|
|
482
|
+
(timeout as { unref?: () => void }).unref?.();
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const ws = await this.connect({
|
|
486
|
+
abortSignal: abortController.signal,
|
|
487
|
+
});
|
|
488
|
+
if (this.terminal || this.closing) {
|
|
489
|
+
closeWebSocket({ ws });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
this.wire(ws);
|
|
493
|
+
this.ws = ws;
|
|
494
|
+
this.connected = true;
|
|
495
|
+
// Ask the bridge to replay everything we have not seen, then flush any
|
|
496
|
+
// host → bridge frames produced while we were disconnected.
|
|
497
|
+
this.rawSend(
|
|
498
|
+
JSON.stringify({
|
|
499
|
+
type: 'resume',
|
|
500
|
+
lastSeenEventId: this._lastSeenEventId,
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
this.flushPending();
|
|
504
|
+
for (const handler of this.onReconnectHandlers) handler();
|
|
407
505
|
this.onDebug?.({
|
|
408
|
-
event: '
|
|
409
|
-
|
|
506
|
+
event: 'reconnected',
|
|
507
|
+
attempt,
|
|
410
508
|
lastSeenEventId: this._lastSeenEventId,
|
|
411
|
-
cause,
|
|
412
509
|
});
|
|
413
510
|
return;
|
|
511
|
+
} catch (cause) {
|
|
512
|
+
if (
|
|
513
|
+
this.terminal ||
|
|
514
|
+
this.closing ||
|
|
515
|
+
reconnectAbortController.signal.aborted
|
|
516
|
+
) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const remainingAfterFailure = deadline - Date.now();
|
|
520
|
+
if (remainingAfterFailure <= 0) {
|
|
521
|
+
this.failReconnect({ attempts: attempt, cause });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
await sleep({
|
|
525
|
+
ms: Math.min(delay, remainingAfterFailure),
|
|
526
|
+
abortSignal: reconnectAbortController.signal,
|
|
527
|
+
});
|
|
528
|
+
delay = Math.min(delay * 1.5, this.maxDelayMs);
|
|
529
|
+
} finally {
|
|
530
|
+
clearTimeout(timeout);
|
|
531
|
+
reconnectAbortController.signal.removeEventListener(
|
|
532
|
+
'abort',
|
|
533
|
+
abortConnect,
|
|
534
|
+
);
|
|
535
|
+
if (this.activeConnectAbortController === abortController) {
|
|
536
|
+
this.activeConnectAbortController = undefined;
|
|
537
|
+
}
|
|
414
538
|
}
|
|
415
|
-
|
|
416
|
-
|
|
539
|
+
}
|
|
540
|
+
} finally {
|
|
541
|
+
if (this.reconnectAbortController === reconnectAbortController) {
|
|
542
|
+
this.reconnectAbortController = undefined;
|
|
417
543
|
}
|
|
418
544
|
}
|
|
419
545
|
}
|
|
420
546
|
|
|
547
|
+
private connect({
|
|
548
|
+
abortSignal,
|
|
549
|
+
}: SandboxChannelConnectOptions): Promise<WebSocket> {
|
|
550
|
+
return awaitWebSocketConnection({
|
|
551
|
+
connection: Promise.resolve().then(() =>
|
|
552
|
+
this.connectThunk({ abortSignal }),
|
|
553
|
+
),
|
|
554
|
+
abortSignal,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private abortActiveConnect(): void {
|
|
559
|
+
const controller = this.activeConnectAbortController;
|
|
560
|
+
this.activeConnectAbortController = undefined;
|
|
561
|
+
controller?.abort(new Error('SandboxChannel connection aborted'));
|
|
562
|
+
this.reconnectAbortController?.abort(
|
|
563
|
+
new Error('SandboxChannel connection aborted'),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private failReconnect({
|
|
568
|
+
attempts,
|
|
569
|
+
cause,
|
|
570
|
+
}: {
|
|
571
|
+
attempts: number;
|
|
572
|
+
cause: unknown;
|
|
573
|
+
}): void {
|
|
574
|
+
this.finalizeClose(1006, 'reconnect failed');
|
|
575
|
+
this.onDebug?.({
|
|
576
|
+
event: 'reconnect-failed',
|
|
577
|
+
attempts,
|
|
578
|
+
lastSeenEventId: this._lastSeenEventId,
|
|
579
|
+
cause,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
421
583
|
private rawSend(text: string): void {
|
|
422
584
|
if (this.connected && this.ws) {
|
|
423
585
|
this.ws.send(text);
|
|
@@ -519,6 +681,7 @@ export class SandboxChannel<
|
|
|
519
681
|
|
|
520
682
|
private finalizeClose(code: number, reason: string): void {
|
|
521
683
|
if (this.terminal) return;
|
|
684
|
+
this.abortActiveConnect();
|
|
522
685
|
this.terminal = true;
|
|
523
686
|
this.connected = false;
|
|
524
687
|
for (const h of this.onCloseHandlers) h(code, reason);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function sleep({
|
|
2
|
+
ms,
|
|
3
|
+
abortSignal,
|
|
4
|
+
}: {
|
|
5
|
+
ms: number;
|
|
6
|
+
abortSignal?: AbortSignal;
|
|
7
|
+
}): Promise<void> {
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
if (abortSignal?.aborted) {
|
|
10
|
+
resolve();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let settled = false;
|
|
15
|
+
let onAbort = () => {};
|
|
16
|
+
const finish = () => {
|
|
17
|
+
if (settled) return;
|
|
18
|
+
settled = true;
|
|
19
|
+
abortSignal?.removeEventListener('abort', onAbort);
|
|
20
|
+
resolve();
|
|
21
|
+
};
|
|
22
|
+
const timer = setTimeout(finish, ms);
|
|
23
|
+
(timer as { unref?: () => void }).unref?.();
|
|
24
|
+
onAbort = () => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
finish();
|
|
27
|
+
};
|
|
28
|
+
abortSignal?.addEventListener('abort', onAbort, { once: true });
|
|
29
|
+
});
|
|
30
|
+
}
|