@ai-sdk/harness 0.0.0 → 1.0.0-canary.3

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +13 -0
  3. package/README.md +142 -0
  4. package/agent/index.ts +17 -0
  5. package/bridge/index.ts +10 -0
  6. package/dist/agent/index.d.ts +1480 -0
  7. package/dist/agent/index.js +2554 -0
  8. package/dist/agent/index.js.map +1 -0
  9. package/dist/bridge/index.d.ts +111 -0
  10. package/dist/bridge/index.js +414 -0
  11. package/dist/bridge/index.js.map +1 -0
  12. package/dist/index.d.ts +1510 -0
  13. package/dist/index.js +15834 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/observability/index.d.ts +97 -0
  16. package/dist/observability/index.js +225 -0
  17. package/dist/observability/index.js.map +1 -0
  18. package/dist/utils/index.d.ts +196 -0
  19. package/dist/utils/index.js +327 -0
  20. package/dist/utils/index.js.map +1 -0
  21. package/package.json +104 -1
  22. package/src/agent/harness-agent-session.ts +352 -0
  23. package/src/agent/harness-agent-settings.ts +131 -0
  24. package/src/agent/harness-agent-tool-approval-continuation.ts +94 -0
  25. package/src/agent/harness-agent.ts +750 -0
  26. package/src/agent/harness-diagnostics.ts +88 -0
  27. package/src/agent/internal/bootstrap-recipe.ts +124 -0
  28. package/src/agent/internal/bridge-port-registry.ts +52 -0
  29. package/src/agent/internal/harness-stream-text-result.ts +720 -0
  30. package/src/agent/internal/permission-mode.ts +50 -0
  31. package/src/agent/internal/resolve-observability.ts +128 -0
  32. package/src/agent/internal/resume-state-validation.ts +51 -0
  33. package/src/agent/internal/run-prompt.ts +811 -0
  34. package/src/agent/internal/strip-work-dir.ts +68 -0
  35. package/src/agent/internal/to-harness-stream.ts +75 -0
  36. package/src/agent/internal/translate-stream-part.ts +221 -0
  37. package/src/agent/internal/turn-telemetry.ts +359 -0
  38. package/src/agent/prewarm.ts +46 -0
  39. package/src/bridge/index.ts +700 -0
  40. package/src/errors/harness-capability-unsupported-error.ts +41 -0
  41. package/src/errors/harness-error.ts +22 -0
  42. package/src/index.ts +3 -0
  43. package/src/observability/file-reporter.ts +209 -0
  44. package/src/observability/index.ts +13 -0
  45. package/src/observability/trace-tree-reporter.ts +122 -0
  46. package/src/utils/classify-disk-log.ts +43 -0
  47. package/src/utils/index.ts +7 -0
  48. package/src/utils/sandbox-channel.ts +453 -0
  49. package/src/v1/harness-v1-bootstrap.ts +46 -0
  50. package/src/v1/harness-v1-bridge-protocol.ts +310 -0
  51. package/src/v1/harness-v1-builtin-tool.ts +138 -0
  52. package/src/v1/harness-v1-call-warning.ts +22 -0
  53. package/src/v1/harness-v1-diagnostic.ts +66 -0
  54. package/src/v1/harness-v1-metadata.ts +13 -0
  55. package/src/v1/harness-v1-network-sandbox-session.ts +123 -0
  56. package/src/v1/harness-v1-observability.ts +20 -0
  57. package/src/v1/harness-v1-permission-mode.ts +11 -0
  58. package/src/v1/harness-v1-prompt-control.ts +41 -0
  59. package/src/v1/harness-v1-prompt.ts +11 -0
  60. package/src/v1/harness-v1-resume-state.ts +46 -0
  61. package/src/v1/harness-v1-sandbox-provider.ts +76 -0
  62. package/src/v1/harness-v1-session.ts +268 -0
  63. package/src/v1/harness-v1-skill.ts +22 -0
  64. package/src/v1/harness-v1-stream-part.ts +363 -0
  65. package/src/v1/harness-v1-tool-spec.ts +31 -0
  66. package/src/v1/harness-v1.ts +83 -0
  67. package/src/v1/index.ts +93 -0
  68. package/utils/index.ts +1 -0
@@ -0,0 +1,453 @@
1
+ import {
2
+ safeParseJSON,
3
+ safeValidateTypes,
4
+ type FlexibleSchema,
5
+ } from '@ai-sdk/provider-utils';
6
+ import type { WebSocket } from 'ws';
7
+
8
+ /**
9
+ * Diagnostic event surfaced by {@link SandboxChannel} during its connection
10
+ * lifecycle. Silent unless a consumer wires `onDebug`. Reconnects are otherwise
11
+ * invisible — the channel reconnects transparently and the in-flight turn keeps
12
+ * streaming.
13
+ */
14
+ export type SandboxChannelDebugEvent =
15
+ | { event: 'reconnect-attempt'; attempt: number; lastSeenEventId: number }
16
+ | { event: 'reconnected'; attempt: number; lastSeenEventId: number }
17
+ | {
18
+ event: 'reconnect-failed';
19
+ attempts: number;
20
+ lastSeenEventId: number;
21
+ cause: unknown;
22
+ };
23
+
24
+ export interface SandboxChannelReconnectOptions {
25
+ /** Give up reconnecting after this many milliseconds. Default 30_000. */
26
+ readonly maxElapsedMs?: number;
27
+ /** First backoff delay. Default 50. */
28
+ readonly initialDelayMs?: number;
29
+ /** Backoff ceiling. Default 2_000. */
30
+ readonly maxDelayMs?: number;
31
+ }
32
+
33
+ export interface SandboxChannelOptions<TOut> {
34
+ /**
35
+ * Open a fresh WebSocket to the bridge and resolve once it is ready to carry
36
+ * frames (i.e. after any adapter-specific handshake such as Claude Code's
37
+ * `bridge-hello`). Called once by {@link SandboxChannel.open} and again on
38
+ * every transient reconnect. Must reject if the connection cannot be
39
+ * established.
40
+ */
41
+ connect: () => Promise<WebSocket>;
42
+
43
+ /** Schema validating inbound (bridge → host) frames. */
44
+ outboundSchema: FlexibleSchema<TOut>;
45
+
46
+ reconnect?: SandboxChannelReconnectOptions;
47
+
48
+ onDebug?: (event: SandboxChannelDebugEvent) => void;
49
+
50
+ /**
51
+ * Sink for forwarded bridge diagnostics — `sandbox-log` (captured
52
+ * console lines) and `debug-event` (structured) frames. When set, these
53
+ * frame types are routed here instead of the per-type listener dispatch, so
54
+ * they never reach the consumer's stream. Typed to the diagnostic members of
55
+ * `TOut`, so it is a no-op union for channels whose protocol has none.
56
+ */
57
+ onDiagnostic?: (
58
+ event: Extract<TOut, { type: 'sandbox-log' | 'debug-event' }>,
59
+ ) => void;
60
+
61
+ /**
62
+ * Seed the host-side cursor before the first connect. Pass the
63
+ * `lastSeenEventId` persisted from a prior process so the bridge replays only
64
+ * events past it when this channel opens with `{ resume: true }` — the
65
+ * cross-process attach handshake. Defaults to `0` (fresh session).
66
+ */
67
+ initialLastSeenEventId?: number;
68
+ }
69
+
70
+ type EventTypeOf<TOut extends { type: string }> = TOut['type'];
71
+
72
+ type Listener<TOut extends { type: string }, T extends EventTypeOf<TOut>> = (
73
+ event: Extract<TOut, { type: T }>,
74
+ ) => void;
75
+
76
+ const sleep = (ms: number): Promise<void> =>
77
+ new Promise(resolve => {
78
+ const t = setTimeout(resolve, ms);
79
+ (t as { unref?: () => void }).unref?.();
80
+ });
81
+
82
+ /**
83
+ * Host-side typed wrapper around the bridge WebSocket connection.
84
+ *
85
+ * Buffers inbound messages until a listener for their type is registered, so
86
+ * callers that subscribe asynchronously do not miss early frames. Inbound
87
+ * dispatch is serialised through a promise chain so a `close` event that
88
+ * arrives on the same microtask as the final `finish` message does not fire
89
+ * close handlers until the message has been dispatched.
90
+ *
91
+ * Survives transient disconnects. The bridge keeps running and
92
+ * accumulates events in an in-memory log keyed by a monotonic `seq`; on an
93
+ * unexpected socket drop this channel re-invokes `connect`, re-wires the new
94
+ * socket, and asks the bridge to replay everything past `lastSeenEventId`. The
95
+ * in-flight turn never observes the blip — `onClose` fires only after a
96
+ * host-initiated close or once the reconnect budget is exhausted.
97
+ */
98
+ export class SandboxChannel<
99
+ TOut extends { type: string },
100
+ TIn extends { type: string } = { type: string },
101
+ > {
102
+ private readonly listeners = new Map<
103
+ EventTypeOf<TOut>,
104
+ Set<Listener<TOut, EventTypeOf<TOut>>>
105
+ >();
106
+ private readonly buffered = new Map<EventTypeOf<TOut>, TOut[]>();
107
+ private readonly onCloseHandlers = new Set<
108
+ (code: number, reason: string) => void
109
+ >();
110
+
111
+ private readonly connectThunk: () => Promise<WebSocket>;
112
+ private readonly outboundSchema: FlexibleSchema<TOut>;
113
+ private readonly onDebug:
114
+ | ((event: SandboxChannelDebugEvent) => void)
115
+ | undefined;
116
+ private readonly onDiagnostic:
117
+ | ((event: Extract<TOut, { type: 'sandbox-log' | 'debug-event' }>) => void)
118
+ | undefined;
119
+ private readonly maxElapsedMs: number;
120
+ private readonly initialDelayMs: number;
121
+ private readonly maxDelayMs: number;
122
+
123
+ private ws: WebSocket | undefined;
124
+ private connected = false;
125
+ /** Host has begun teardown; suppresses reconnect so a bridge-side close finalises. */
126
+ private closing = false;
127
+ /**
128
+ * Host has gracefully suspended (slice boundary). Inbound frames are ignored
129
+ * from this point so the cursor stops advancing exactly at the last delivered
130
+ * event — the bridge keeps the turn running and the not-yet-delivered tail is
131
+ * replayed to the next process on `resume`.
132
+ */
133
+ private suspended = false;
134
+ /** Channel is fully torn down; `send` throws and `onClose` has fired. */
135
+ private terminal = false;
136
+ private _lastSeenEventId = 0;
137
+ private readonly pendingSends: string[] = [];
138
+ private dispatchChain: Promise<void> = Promise.resolve();
139
+
140
+ constructor(options: SandboxChannelOptions<TOut>) {
141
+ this.connectThunk = options.connect;
142
+ this.outboundSchema = options.outboundSchema;
143
+ this.onDebug = options.onDebug;
144
+ this.onDiagnostic = options.onDiagnostic;
145
+ this.maxElapsedMs = options.reconnect?.maxElapsedMs ?? 30_000;
146
+ this.initialDelayMs = options.reconnect?.initialDelayMs ?? 50;
147
+ this.maxDelayMs = options.reconnect?.maxDelayMs ?? 2_000;
148
+ this._lastSeenEventId = options.initialLastSeenEventId ?? 0;
149
+ }
150
+
151
+ /**
152
+ * Highest bridge event `seq` this channel has observed. Persist it (e.g. via
153
+ * the adapter's resume handle) so a future process can seed
154
+ * {@link SandboxChannelOptions.initialLastSeenEventId} and attach.
155
+ */
156
+ get lastSeenEventId(): number {
157
+ return this._lastSeenEventId;
158
+ }
159
+
160
+ /**
161
+ * Establish the initial connection. A single attempt — startup failures
162
+ * reject so the caller can fail `doStart` cleanly. Reconnect retries apply
163
+ * only to drops after a successful open.
164
+ *
165
+ * Pass `{ resume: true }` to attach to a bridge that is already mid-session:
166
+ * after the socket opens, the channel sends `{ type: 'resume', lastSeenEventId }`
167
+ * so the bridge replays everything past the seeded cursor. This is the
168
+ * cross-process attach handshake — identical to what a transient reconnect
169
+ * does, but triggered by the initial open from a new process.
170
+ */
171
+ async open(opts?: { resume?: boolean }): Promise<void> {
172
+ if (this.terminal) {
173
+ throw new Error('SandboxChannel: cannot open a closed channel.');
174
+ }
175
+ const ws = await this.connectThunk();
176
+ this.wire(ws);
177
+ this.ws = ws;
178
+ this.connected = true;
179
+ if (opts?.resume) {
180
+ this.rawSend(
181
+ JSON.stringify({
182
+ type: 'resume',
183
+ lastSeenEventId: this._lastSeenEventId,
184
+ }),
185
+ );
186
+ }
187
+ }
188
+
189
+ on<T extends EventTypeOf<TOut>>(
190
+ type: T,
191
+ listener: Listener<TOut, T>,
192
+ ): () => void {
193
+ let set = this.listeners.get(type);
194
+ if (!set) {
195
+ set = new Set();
196
+ this.listeners.set(type, set);
197
+ }
198
+ set.add(listener as unknown as Listener<TOut, EventTypeOf<TOut>>);
199
+
200
+ const buffered = this.buffered.get(type);
201
+ if (buffered) {
202
+ this.buffered.delete(type);
203
+ for (const event of buffered) {
204
+ listener(event as Extract<TOut, { type: T }>);
205
+ }
206
+ }
207
+
208
+ return () => {
209
+ set!.delete(listener as unknown as Listener<TOut, EventTypeOf<TOut>>);
210
+ };
211
+ }
212
+
213
+ onClose(handler: (code: number, reason: string) => void): void {
214
+ this.onCloseHandlers.add(handler);
215
+ }
216
+
217
+ send(message: TIn): void {
218
+ if (this.terminal) {
219
+ throw new Error(
220
+ `SandboxChannel: cannot send ${message.type} — channel is closed.`,
221
+ );
222
+ }
223
+ this.rawSend(JSON.stringify(message));
224
+ }
225
+
226
+ /**
227
+ * Mark that the host is tearing the session down. The next socket close is
228
+ * then treated as terminal rather than triggering a reconnect. Call before
229
+ * sending a `shutdown` / `detach` message whose ack the bridge follows with a
230
+ * socket close.
231
+ */
232
+ beginClose(): void {
233
+ this.closing = true;
234
+ }
235
+
236
+ close(): void {
237
+ if (this.terminal) return;
238
+ this.closing = true;
239
+ try {
240
+ this.ws?.close();
241
+ } catch {
242
+ // best-effort
243
+ }
244
+ this.enqueue(() => this.finalizeClose(1000, 'closed'));
245
+ }
246
+
247
+ /**
248
+ * Gracefully suspend at a slice boundary: stop processing inbound frames
249
+ * (so the cursor freezes at the last delivered event), drain any frames
250
+ * already queued for dispatch, then close the socket and finalise with the
251
+ * close reason `'suspended'`. Resolves with the final `lastSeenEventId`.
252
+ *
253
+ * The bridge keeps the in-flight turn running (a host socket close never
254
+ * aborts it) and accumulates events past the cursor for the next process to
255
+ * `resume`. Unlike {@link close}, the consumer's active turn is wound down
256
+ * cleanly — adapters distinguish a suspend from an unexpected drop via the
257
+ * `'suspended'` close reason and resolve `done` successfully.
258
+ */
259
+ suspend(): Promise<number> {
260
+ return new Promise<number>(resolve => {
261
+ if (this.terminal) {
262
+ resolve(this._lastSeenEventId);
263
+ return;
264
+ }
265
+ // Stop counting/dispatching further inbound frames immediately, and
266
+ // suppress reconnect so the socket close finalises.
267
+ this.suspended = true;
268
+ this.closing = true;
269
+ this.onClose(() => resolve(this._lastSeenEventId));
270
+ // Queue the close behind any already-dispatched frames so everything
271
+ // delivered to the consumer is reflected in the final cursor.
272
+ this.enqueue(() => {
273
+ try {
274
+ this.ws?.close();
275
+ } catch {
276
+ // best-effort
277
+ }
278
+ this.finalizeClose(1000, 'suspended');
279
+ });
280
+ });
281
+ }
282
+
283
+ isClosed(): boolean {
284
+ return this.terminal;
285
+ }
286
+
287
+ // ─── internals ──────────────────────────────────────────────────────
288
+
289
+ private wire(ws: WebSocket): void {
290
+ let dropped = false;
291
+ const onDrop = (code: number, reason: string) => {
292
+ if (dropped) return;
293
+ dropped = true;
294
+ if (ws !== this.ws) return;
295
+ this.connected = false;
296
+ if (this.closing) {
297
+ this.enqueue(() => this.finalizeClose(code, reason));
298
+ } else {
299
+ this.enqueue(() => this.reconnectLoop());
300
+ }
301
+ };
302
+
303
+ ws.on('message', (raw: ArrayBufferLike | string) => {
304
+ // Once suspended, ignore further frames so the cursor freezes exactly at
305
+ // the last delivered event (the bridge replays the rest to the next slice).
306
+ if (this.suspended) return;
307
+ const text =
308
+ typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8');
309
+ this.enqueue(() => this.handleIncoming(text));
310
+ });
311
+ ws.on('close', (code: number, reason: Buffer) =>
312
+ onDrop(code, reason?.toString?.('utf8') ?? ''),
313
+ );
314
+ ws.on('error', () => onDrop(1006, 'socket error'));
315
+ }
316
+
317
+ private async reconnectLoop(): Promise<void> {
318
+ if (this.terminal || this.closing) return;
319
+ const start = Date.now();
320
+ let attempt = 0;
321
+ let delay = this.initialDelayMs;
322
+ while (!this.terminal && !this.closing) {
323
+ attempt++;
324
+ this.onDebug?.({
325
+ event: 'reconnect-attempt',
326
+ attempt,
327
+ lastSeenEventId: this._lastSeenEventId,
328
+ });
329
+ try {
330
+ const ws = await this.connectThunk();
331
+ if (this.terminal || this.closing) {
332
+ try {
333
+ ws.close();
334
+ } catch {
335
+ // best-effort
336
+ }
337
+ return;
338
+ }
339
+ this.wire(ws);
340
+ this.ws = ws;
341
+ this.connected = true;
342
+ // Ask the bridge to replay everything we have not seen, then flush any
343
+ // host → bridge frames produced while we were disconnected.
344
+ this.rawSend(
345
+ JSON.stringify({
346
+ type: 'resume',
347
+ lastSeenEventId: this._lastSeenEventId,
348
+ }),
349
+ );
350
+ this.flushPending();
351
+ this.onDebug?.({
352
+ event: 'reconnected',
353
+ attempt,
354
+ lastSeenEventId: this._lastSeenEventId,
355
+ });
356
+ return;
357
+ } catch (cause) {
358
+ if (Date.now() - start >= this.maxElapsedMs) {
359
+ this.finalizeClose(1006, 'reconnect failed');
360
+ this.onDebug?.({
361
+ event: 'reconnect-failed',
362
+ attempts: attempt,
363
+ lastSeenEventId: this._lastSeenEventId,
364
+ cause,
365
+ });
366
+ return;
367
+ }
368
+ await sleep(delay);
369
+ delay = Math.min(delay * 1.5, this.maxDelayMs);
370
+ }
371
+ }
372
+ }
373
+
374
+ private rawSend(text: string): void {
375
+ if (this.connected && this.ws) {
376
+ this.ws.send(text);
377
+ } else {
378
+ this.pendingSends.push(text);
379
+ }
380
+ }
381
+
382
+ private flushPending(): void {
383
+ if (!this.connected || !this.ws) return;
384
+ const queued = this.pendingSends.splice(0);
385
+ for (const text of queued) this.ws.send(text);
386
+ }
387
+
388
+ private enqueue(work: () => void | Promise<void>): void {
389
+ this.dispatchChain = this.dispatchChain.then(work);
390
+ }
391
+
392
+ private async handleIncoming(text: string): Promise<void> {
393
+ const raw = await safeParseJSON({ text });
394
+ let seq: number | undefined;
395
+ if (
396
+ raw.success &&
397
+ raw.value != null &&
398
+ typeof raw.value === 'object' &&
399
+ typeof (raw.value as { seq?: unknown }).seq === 'number'
400
+ ) {
401
+ seq = (raw.value as { seq: number }).seq;
402
+ }
403
+
404
+ const validated = await safeValidateTypes({
405
+ value: raw.success ? raw.value : undefined,
406
+ schema: this.outboundSchema,
407
+ });
408
+ if (validated.success) {
409
+ this.dispatch(validated.value);
410
+ } else {
411
+ this.dispatch({
412
+ type: 'error',
413
+ error: validated.error,
414
+ } as unknown as TOut);
415
+ }
416
+
417
+ if (seq !== undefined && seq > this._lastSeenEventId) {
418
+ this._lastSeenEventId = seq;
419
+ }
420
+ }
421
+
422
+ private dispatch(message: TOut): void {
423
+ // Diagnostics are routed to their own sink and never enter the
424
+ // per-type listener/buffer path, so they stay off the consumer stream.
425
+ if (message.type === 'sandbox-log' || message.type === 'debug-event') {
426
+ this.onDiagnostic?.(
427
+ message as Extract<TOut, { type: 'sandbox-log' | 'debug-event' }>,
428
+ );
429
+ return;
430
+ }
431
+ const type = message.type as EventTypeOf<TOut>;
432
+ const set = this.listeners.get(type);
433
+ if (!set || set.size === 0) {
434
+ let bucket = this.buffered.get(type);
435
+ if (!bucket) {
436
+ bucket = [];
437
+ this.buffered.set(type, bucket);
438
+ }
439
+ bucket.push(message);
440
+ return;
441
+ }
442
+ for (const listener of set) {
443
+ listener(message as Extract<TOut, { type: EventTypeOf<TOut> }>);
444
+ }
445
+ }
446
+
447
+ private finalizeClose(code: number, reason: string): void {
448
+ if (this.terminal) return;
449
+ this.terminal = true;
450
+ this.connected = false;
451
+ for (const h of this.onCloseHandlers) h(code, reason);
452
+ }
453
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * One file to write into the sandbox as part of an adapter's bootstrap recipe.
3
+ * Paths should live under {@link HarnessV1Bootstrap.bootstrapDir}.
4
+ */
5
+ export interface HarnessV1BootstrapFile {
6
+ readonly path: string;
7
+ readonly content: string;
8
+ }
9
+
10
+ /**
11
+ * One command to run in the sandbox as part of an adapter's bootstrap recipe.
12
+ * Commands run sequentially after all files have been written; a non-zero exit
13
+ * aborts the bootstrap.
14
+ */
15
+ export interface HarnessV1BootstrapCommand {
16
+ readonly command: string;
17
+ readonly workingDirectory?: string;
18
+ }
19
+
20
+ /**
21
+ * Adapter-owned bootstrap recipe. The adapter declares the files and commands
22
+ * needed to set up its bridge inside any sandbox. The harness framework hashes
23
+ * the recipe into an identity used by sandbox providers for snapshot-based
24
+ * reuse, and applies the recipe idempotently before the bridge spawns.
25
+ */
26
+ export interface HarnessV1Bootstrap {
27
+ /**
28
+ * Stable id of the adapter that owns this recipe. Conventionally matches
29
+ * {@link HarnessV1.harnessId}. Contributes to the recipe hash.
30
+ */
31
+ readonly harnessId: string;
32
+
33
+ /**
34
+ * Absolute path inside the sandbox where this recipe writes its state.
35
+ * The marker file lives directly under it. Files declared in {@link files}
36
+ * should also use this prefix so an adapter upgrade can sweep stale state
37
+ * by clearing the directory.
38
+ */
39
+ readonly bootstrapDir: string;
40
+
41
+ /** Files to write into the sandbox before any command runs. */
42
+ readonly files: ReadonlyArray<HarnessV1BootstrapFile>;
43
+
44
+ /** Commands to run after files are written, in order. */
45
+ readonly commands: ReadonlyArray<HarnessV1BootstrapCommand>;
46
+ }