@ai-sdk/harness 1.0.76 → 1.0.78

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.
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { appendFile, mkdir, writeFile } from 'node:fs/promises';
11
11
  import { existsSync, readFileSync } from 'node:fs';
12
+ import { randomUUID } from 'node:crypto';
12
13
  import { env as procEnv, pid, stdout } from 'node:process';
13
14
  import { WebSocketServer, type WebSocket } from 'ws';
14
15
 
@@ -21,6 +22,29 @@ export type BridgeEvent = Record<string, unknown> & { type: string };
21
22
 
22
23
  export type BridgeDebugLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace';
23
24
 
25
+ export interface Experimental_BridgeUserMessage {
26
+ readonly messageId: string;
27
+ readonly text: string;
28
+ accept(): void;
29
+ reject(error: unknown): void;
30
+ }
31
+
32
+ export interface Experimental_BridgeUserMessageQueue extends AsyncIterable<Experimental_BridgeUserMessage> {
33
+ readonly pendingCount: number;
34
+ close(error?: unknown): void;
35
+ }
36
+
37
+ type InternalBridgeUserMessageQueue = Experimental_BridgeUserMessageQueue & {
38
+ enqueue(input: { messageId: string; text: string }): void;
39
+ };
40
+
41
+ type BridgeUserMessageResponse = {
42
+ type: 'user-message-response';
43
+ messageId: string;
44
+ accepted: boolean;
45
+ error?: { message: string };
46
+ };
47
+
24
48
  /**
25
49
  * Per-session diagnostics config. The host resolves it from settings +
26
50
  * env and sends it on `start.debug`; the bridge gates console capture and
@@ -71,6 +95,125 @@ function formatBridgeError(err: unknown): {
71
95
  return { message: String(err) };
72
96
  }
73
97
 
98
+ function createBridgeUserMessageQueue(options: {
99
+ respond(response: BridgeUserMessageResponse): void;
100
+ }): InternalBridgeUserMessageQueue {
101
+ const messages: Experimental_BridgeUserMessage[] = [];
102
+ const waiters: Array<
103
+ (result: IteratorResult<Experimental_BridgeUserMessage>) => void
104
+ > = [];
105
+ const entries = new Map<
106
+ string,
107
+ {
108
+ response?: BridgeUserMessageResponse;
109
+ reject(error: unknown): void;
110
+ }
111
+ >();
112
+ let closed = false;
113
+ let pendingCount = 0;
114
+
115
+ const enqueue = (input: { messageId: string; text: string }): void => {
116
+ const existing = entries.get(input.messageId);
117
+ if (existing != null) {
118
+ if (existing.response != null) {
119
+ options.respond(existing.response);
120
+ }
121
+ return;
122
+ }
123
+
124
+ let settled = false;
125
+ const settle = (response: BridgeUserMessageResponse): void => {
126
+ if (settled) return;
127
+ settled = true;
128
+ pendingCount--;
129
+ const entry = entries.get(input.messageId);
130
+ if (entry != null) entry.response = response;
131
+ options.respond(response);
132
+ };
133
+ const message: Experimental_BridgeUserMessage = {
134
+ messageId: input.messageId,
135
+ text: input.text,
136
+ accept: () => {
137
+ settle({
138
+ type: 'user-message-response',
139
+ messageId: input.messageId,
140
+ accepted: true,
141
+ });
142
+ },
143
+ reject: error => {
144
+ settle({
145
+ type: 'user-message-response',
146
+ messageId: input.messageId,
147
+ accepted: false,
148
+ error: { message: formatBridgeError(error).message },
149
+ });
150
+ },
151
+ };
152
+ entries.set(input.messageId, {
153
+ reject: message.reject,
154
+ });
155
+ pendingCount++;
156
+
157
+ if (closed) {
158
+ message.reject(
159
+ new Error('The bridge turn is no longer accepting user messages.'),
160
+ );
161
+ return;
162
+ }
163
+
164
+ const waiter = waiters.shift();
165
+ if (waiter != null) {
166
+ waiter({ done: false, value: message });
167
+ } else {
168
+ messages.push(message);
169
+ }
170
+ };
171
+
172
+ const close = (error?: unknown): void => {
173
+ if (closed) return;
174
+ closed = true;
175
+ const reason =
176
+ error ??
177
+ new Error('The bridge turn ended before accepting the user message.');
178
+ for (const entry of entries.values()) {
179
+ if (entry.response == null) entry.reject(reason);
180
+ }
181
+ messages.length = 0;
182
+ while (waiters.length > 0) {
183
+ waiters.shift()!({ done: true, value: undefined });
184
+ }
185
+ };
186
+
187
+ return {
188
+ get pendingCount() {
189
+ return pendingCount;
190
+ },
191
+ enqueue,
192
+ close,
193
+ [Symbol.asyncIterator]() {
194
+ return {
195
+ next: () => {
196
+ const message = messages.shift();
197
+ if (message != null) {
198
+ return Promise.resolve({ done: false as const, value: message });
199
+ }
200
+ if (closed) {
201
+ return Promise.resolve({
202
+ done: true as const,
203
+ value: undefined,
204
+ });
205
+ }
206
+ return new Promise<IteratorResult<Experimental_BridgeUserMessage>>(
207
+ resolve => {
208
+ waiters.push(resolve);
209
+ },
210
+ );
211
+ },
212
+ };
213
+ },
214
+ };
215
+ }
216
+
74
217
  function parseEnvList(value: string | undefined): string[] | undefined {
75
218
  if (!value) return undefined;
76
219
  const items = value
@@ -113,12 +256,7 @@ export interface BridgeTurn {
113
256
  approvalId: string,
114
257
  ): Promise<{ approved: boolean; reason?: string }>;
115
258
 
116
- /**
117
- * Live queue of mid-turn user messages. The runtime pushes inbound
118
- * `user-message` text here; the adapter drains it as its runtime accepts
119
- * interactive input.
120
- */
121
- readonly pendingUserMessages: string[];
259
+ readonly experimental_userMessages: Experimental_BridgeUserMessageQueue;
122
260
 
123
261
  /** Aborts when the host sends `abort`. */
124
262
  readonly abortSignal: AbortSignal;
@@ -192,7 +330,7 @@ type InboundControl =
192
330
  approved: boolean;
193
331
  reason?: string;
194
332
  }
195
- | { type: 'user-message'; text: string }
333
+ | { type: 'user-message'; messageId?: string; text: string }
196
334
  | { type: 'abort' }
197
335
  | { type: 'stop' }
198
336
  | { type: 'destroy' }
@@ -245,7 +383,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
245
383
  let activeSocket: WebSocket | undefined;
246
384
  let isFirstTurn = true;
247
385
  let turnAbort: AbortController | undefined;
248
- let currentUserMessages: string[] | undefined;
386
+ let currentUserMessages: InternalBridgeUserMessageQueue | undefined;
249
387
 
250
388
  // Diagnostics. Resolved per turn from `start.debug` with a sandbox-side
251
389
  // env fallback; gates console capture + structured `debug-event`s.
@@ -521,6 +659,9 @@ export async function runBridge<TStart extends { type: 'start' }>(
521
659
  switch (msg.type) {
522
660
  case 'start': {
523
661
  activeSocket = ws; // asking for a turn claims the event stream
662
+ currentUserMessages?.close(
663
+ new Error('A new bridge turn replaced the active turn.'),
664
+ );
524
665
  const firstTurn = isFirstTurn;
525
666
  isFirstTurn = false;
526
667
  eventLog = []; // clear previous turn; keep seqCounter monotonic
@@ -545,6 +686,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
545
686
  if (debugConfig.enabled) {
546
687
  installConsoleCapture();
547
688
  }
689
+ const userMessages = createBridgeUserMessageQueue({ respond: emit });
548
690
  const turn: BridgeTurn = {
549
691
  emit,
550
692
  requestToolResult: toolCallId =>
@@ -555,7 +697,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
555
697
  new Promise(resolve => {
556
698
  pendingToolApprovals.set(approvalId, resolve);
557
699
  }),
558
- pendingUserMessages: [],
700
+ experimental_userMessages: userMessages,
559
701
  abortSignal: turnAbort.signal,
560
702
  firstTurn,
561
703
  bridgeLog: input => {
@@ -575,12 +717,16 @@ export async function runBridge<TStart extends { type: 'start' }>(
575
717
  emitWarning,
576
718
  emitError,
577
719
  };
578
- currentUserMessages = turn.pendingUserMessages;
720
+ currentUserMessages = userMessages;
579
721
  try {
580
722
  await onStart(msg as TStart, turn);
581
723
  } catch (err) {
582
724
  emitError({ error: err, message: 'bridge turn failed' });
583
725
  } finally {
726
+ userMessages.close();
727
+ if (currentUserMessages === userMessages) {
728
+ currentUserMessages = undefined;
729
+ }
584
730
  currentTurnState = 'waiting';
585
731
  void writeBridgeMeta('waiting');
586
732
  }
@@ -602,9 +748,34 @@ export async function runBridge<TStart extends { type: 'start' }>(
602
748
  }
603
749
  return;
604
750
  }
605
- case 'user-message':
606
- currentUserMessages?.push(msg.text);
751
+ case 'user-message': {
752
+ const messageId = msg.messageId ?? randomUUID();
753
+ if (currentUserMessages == null) {
754
+ sendControl(ws, {
755
+ type: 'user-message-response',
756
+ messageId,
757
+ accepted: false,
758
+ error: { message: 'The bridge has no active turn to steer.' },
759
+ });
760
+ return;
761
+ }
762
+ if (ws !== activeSocket) {
763
+ sendControl(ws, {
764
+ type: 'user-message-response',
765
+ messageId,
766
+ accepted: false,
767
+ error: {
768
+ message: 'The connection does not own the active bridge turn.',
769
+ },
770
+ });
771
+ return;
772
+ }
773
+ currentUserMessages.enqueue({
774
+ messageId,
775
+ text: msg.text,
776
+ });
607
777
  return;
778
+ }
608
779
  case 'abort':
609
780
  turnAbort?.abort();
610
781
  return;
@@ -693,6 +864,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
693
864
  type: 'bridge-hello',
694
865
  state: currentTurnState,
695
866
  lastSeq: seqCounter,
867
+ capabilities: { experimental_userMessageResponses: true },
696
868
  });
697
869
 
698
870
  ws.on('message', (raw: ArrayBufferLike | string) => {
@@ -0,0 +1,96 @@
1
+ export type Experimental_BridgeUserMessageRequest = {
2
+ type: 'user-message';
3
+ messageId: string;
4
+ text: string;
5
+ };
6
+
7
+ export type Experimental_BridgeUserMessageResponse = {
8
+ type: 'user-message-response';
9
+ messageId: string;
10
+ accepted: boolean;
11
+ error?: { message: string };
12
+ };
13
+
14
+ export type Experimental_BridgeUserMessageSubmitter = {
15
+ submit(text: string): Promise<void>;
16
+ close(error?: unknown): void;
17
+ };
18
+
19
+ export function experimental_createBridgeUserMessageSubmitter(options: {
20
+ send(message: Experimental_BridgeUserMessageRequest): void;
21
+ onResponse(
22
+ listener: (response: Experimental_BridgeUserMessageResponse) => void,
23
+ ): () => void;
24
+ onReconnect(listener: () => void): () => void;
25
+ }): Experimental_BridgeUserMessageSubmitter {
26
+ const pending = new Map<
27
+ string,
28
+ {
29
+ request: Experimental_BridgeUserMessageRequest;
30
+ resolve(): void;
31
+ reject(error: unknown): void;
32
+ }
33
+ >();
34
+ let closed = false;
35
+
36
+ const send = (request: Experimental_BridgeUserMessageRequest): void => {
37
+ try {
38
+ options.send(request);
39
+ } catch (error) {
40
+ const entry = pending.get(request.messageId);
41
+ if (entry == null) return;
42
+ pending.delete(request.messageId);
43
+ entry.reject(error);
44
+ }
45
+ };
46
+
47
+ const unsubscribeResponse = options.onResponse(response => {
48
+ const entry = pending.get(response.messageId);
49
+ if (entry == null) return;
50
+ pending.delete(response.messageId);
51
+ if (response.accepted) {
52
+ entry.resolve();
53
+ } else {
54
+ entry.reject(
55
+ new Error(
56
+ response.error?.message ?? 'The runtime rejected the user message.',
57
+ ),
58
+ );
59
+ }
60
+ });
61
+ const unsubscribeReconnect = options.onReconnect(() => {
62
+ for (const entry of pending.values()) send(entry.request);
63
+ });
64
+
65
+ return {
66
+ submit: text => {
67
+ if (closed) {
68
+ return Promise.reject(
69
+ new Error('The bridge turn is no longer accepting user messages.'),
70
+ );
71
+ }
72
+ const messageId = crypto.randomUUID();
73
+ const request: Experimental_BridgeUserMessageRequest = {
74
+ type: 'user-message',
75
+ messageId,
76
+ text,
77
+ };
78
+ const promise = new Promise<void>((resolve, reject) => {
79
+ pending.set(messageId, { request, resolve, reject });
80
+ });
81
+ send(request);
82
+ return promise;
83
+ },
84
+ close: error => {
85
+ if (closed) return;
86
+ closed = true;
87
+ unsubscribeResponse();
88
+ unsubscribeReconnect();
89
+ const reason =
90
+ error ??
91
+ new Error('The bridge turn ended before accepting the user message.');
92
+ for (const entry of pending.values()) entry.reject(reason);
93
+ pending.clear();
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,10 @@
1
+ import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
2
+ import type { HarnessV1NetworkSandboxSession } from '../v1';
3
+
4
+ export function getRestrictedSandboxSession(
5
+ sandboxSession: HarnessV1NetworkSandboxSession | SandboxSession,
6
+ ): SandboxSession {
7
+ return 'restricted' in sandboxSession
8
+ ? sandboxSession.restricted()
9
+ : sandboxSession;
10
+ }
@@ -4,6 +4,12 @@ export {
4
4
  type SandboxChannelOptions,
5
5
  type SandboxChannelReconnectOptions,
6
6
  } from './sandbox-channel';
7
+ export {
8
+ experimental_createBridgeUserMessageSubmitter,
9
+ type Experimental_BridgeUserMessageRequest,
10
+ type Experimental_BridgeUserMessageResponse,
11
+ type Experimental_BridgeUserMessageSubmitter,
12
+ } from './bridge-user-message-submitter';
7
13
  export { classifyDiskLog, type DiskLogRecoveryMode } from './classify-disk-log';
8
14
  export { getAiGatewayAuthFromEnv } from './ai-gateway-auth';
9
15
  export {
@@ -34,3 +40,5 @@ export {
34
40
  forwardBridgeProcessStream,
35
41
  logBridgeError,
36
42
  } from './bridge-diagnostics';
43
+ export { resolveSandboxDefaultWorkingDirectory } from './resolve-sandbox-default-working-directory';
44
+ export { getRestrictedSandboxSession } from './get-restricted-sandbox-session';
@@ -0,0 +1,33 @@
1
+ import { posix } from 'node:path';
2
+ import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
3
+ import type { HarnessV1NetworkSandboxSession } from '../v1';
4
+
5
+ export async function resolveSandboxDefaultWorkingDirectory({
6
+ sandboxSession,
7
+ abortSignal,
8
+ }: {
9
+ readonly sandboxSession: HarnessV1NetworkSandboxSession | SandboxSession;
10
+ readonly abortSignal?: AbortSignal;
11
+ }): Promise<string> {
12
+ if ('defaultWorkingDirectory' in sandboxSession) {
13
+ return sandboxSession.defaultWorkingDirectory;
14
+ }
15
+
16
+ const result = await sandboxSession.run({
17
+ command: 'pwd',
18
+ abortSignal,
19
+ });
20
+ if (result.exitCode !== 0) {
21
+ throw new Error(
22
+ `Failed to resolve sandbox default working directory (exit ${result.exitCode}): ${result.stderr || result.stdout}`,
23
+ );
24
+ }
25
+
26
+ const cwd = result.stdout.trim();
27
+ if (!posix.isAbsolute(cwd)) {
28
+ throw new Error(
29
+ `Failed to resolve sandbox default working directory: expected an absolute path, got ${JSON.stringify(cwd)}.`,
30
+ );
31
+ }
32
+ return cwd === '/' ? cwd : cwd.replace(/\/+$/, '');
33
+ }
@@ -133,6 +133,7 @@ export class SandboxChannel<
133
133
  private readonly onCloseHandlers = new Set<
134
134
  (code: number, reason: string) => void
135
135
  >();
136
+ private readonly onReconnectHandlers = new Set<() => void>();
136
137
 
137
138
  private readonly connectThunk: () => Promise<WebSocket>;
138
139
  private readonly outboundSchema: FlexibleSchema<TOut>;
@@ -247,6 +248,13 @@ export class SandboxChannel<
247
248
  this.onCloseHandlers.add(handler);
248
249
  }
249
250
 
251
+ onReconnect(handler: () => void): () => void {
252
+ this.onReconnectHandlers.add(handler);
253
+ return () => {
254
+ this.onReconnectHandlers.delete(handler);
255
+ };
256
+ }
257
+
250
258
  send(message: TIn): void {
251
259
  if (this.terminal) {
252
260
  throw new Error(
@@ -386,6 +394,7 @@ export class SandboxChannel<
386
394
  }),
387
395
  );
388
396
  this.flushPending();
397
+ for (const handler of this.onReconnectHandlers) handler();
389
398
  this.onDebug?.({
390
399
  event: 'reconnected',
391
400
  attempt,
@@ -133,6 +133,18 @@ export const harnessV1BridgeHelloSchema = z.object({
133
133
  type: z.literal('bridge-hello'),
134
134
  state: z.string().optional(),
135
135
  lastSeq: z.number().optional(),
136
+ capabilities: z
137
+ .object({
138
+ experimental_userMessageResponses: z.boolean().optional(),
139
+ })
140
+ .optional(),
141
+ });
142
+
143
+ export const experimental_harnessV1BridgeUserMessageResponseSchema = z.object({
144
+ type: z.literal('user-message-response'),
145
+ messageId: z.string(),
146
+ accepted: z.boolean(),
147
+ error: z.object({ message: z.string() }).optional(),
136
148
  });
137
149
 
138
150
  /**
@@ -211,6 +223,7 @@ export const harnessV1BridgeOutboundMessageSchema = z.discriminatedUnion(
211
223
  harnessV1ErrorPartSchema,
212
224
  harnessV1RawPartSchema,
213
225
  harnessV1BridgeHelloSchema,
226
+ experimental_harnessV1BridgeUserMessageResponseSchema,
214
227
  harnessV1BridgeStopSchema,
215
228
  harnessV1BridgeThreadSchema,
216
229
  harnessV1BridgeSandboxLogSchema,
@@ -222,6 +235,10 @@ export type HarnessV1BridgeOutboundMessage = z.infer<
222
235
  typeof harnessV1BridgeOutboundMessageSchema
223
236
  >;
224
237
 
238
+ export type Experimental_HarnessV1BridgeUserMessageResponse = z.infer<
239
+ typeof experimental_harnessV1BridgeUserMessageResponseSchema
240
+ >;
241
+
225
242
  export type HarnessV1BridgeSandboxLog = z.infer<
226
243
  typeof harnessV1BridgeSandboxLogSchema
227
244
  >;
@@ -283,9 +300,15 @@ export const harnessV1BridgeToolApprovalResponseInboundSchema = z.object({
283
300
 
284
301
  export const harnessV1BridgeUserMessageInboundSchema = z.object({
285
302
  type: z.literal('user-message'),
303
+ messageId: z.string().optional(),
286
304
  text: z.string(),
287
305
  });
288
306
 
307
+ export const experimental_harnessV1BridgeUserMessageInboundSchema =
308
+ harnessV1BridgeUserMessageInboundSchema.extend({
309
+ messageId: z.string(),
310
+ });
311
+
289
312
  export const harnessV1BridgeAbortInboundSchema = z.object({
290
313
  type: z.literal('abort'),
291
314
  });
@@ -1,4 +1,5 @@
1
1
  import type { HarnessV1NetworkSandboxSession } from './harness-v1-network-sandbox-session';
2
+ import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
2
3
  import type { HarnessV1Observability } from './harness-v1-observability';
3
4
  import type { HarnessV1PermissionMode } from './harness-v1-permission-mode';
4
5
  import type { HarnessV1Prompt } from './harness-v1-prompt';
@@ -76,21 +77,17 @@ export type HarnessV1StartOptions = {
76
77
  */
77
78
  readonly observability?: HarnessV1Observability;
78
79
  /**
79
- * Network sandbox session the adapter operates against. It is owned and
80
- * lifecycled by `HarnessAgent`. Adapters call `restricted()` for the
81
- * tool-safe filesystem/exec/spawn surface, and use the infra methods
82
- * (`getPortEndpoint`, `ports`, `setNetworkPolicy`,
83
- * `setRequestTransformations`, `addRequestTransformations`) for bridge
84
- * wiring. Adapters must not call `stop()` themselves; the agent does that
85
- * during cleanup.
80
+ * Sandbox session the adapter operates against. Network sandbox sessions
81
+ * expose optional infrastructure capabilities for bridge wiring; caller-
82
+ * provided basic sandbox sessions expose only filesystem and process APIs.
83
+ * Adapters must not stop or destroy the sandbox themselves.
86
84
  */
87
- readonly sandboxSession: HarnessV1NetworkSandboxSession;
85
+ readonly sandboxSession: HarnessV1NetworkSandboxSession | SandboxSession;
88
86
 
89
87
  /**
90
- * Absolute path the adapter runs the agent in for this session. Composed by
91
- * the framework as `<sandboxSession.defaultWorkingDirectory>/<harnessId>-<sessionId>`
92
- * and created before `doStart`, so the adapter uses it directly instead of
93
- * deriving its own provider-specific path.
88
+ * Absolute path the adapter runs the agent in for this session. Composed
89
+ * underneath the sandbox's resolved default working directory and created
90
+ * before `doStart`.
94
91
  */
95
92
  readonly sessionWorkDir: string;
96
93
  };
package/src/v1/index.ts CHANGED
@@ -73,6 +73,7 @@ export {
73
73
  harnessV1BridgeDestroyInboundSchema,
74
74
  harnessV1BridgeHelloSchema,
75
75
  harnessV1BridgeInboundCommandSchemas,
76
+ harnessV1BridgeUserMessageInboundSchema,
76
77
  harnessV1BridgeOutboundMessageSchema,
77
78
  harnessV1BridgeReadySchema,
78
79
  harnessV1BridgeResponseFormatSchema,
@@ -86,13 +87,15 @@ export {
86
87
  harnessV1BridgeToolResultInboundSchema,
87
88
  harnessV1BridgePermissionModeSchema,
88
89
  harnessV1BridgeToolWireSchema,
89
- harnessV1BridgeUserMessageInboundSchema,
90
+ experimental_harnessV1BridgeUserMessageInboundSchema,
91
+ experimental_harnessV1BridgeUserMessageResponseSchema,
90
92
  harnessV1DiagnosticFromBridgeFrame,
91
93
  type HarnessV1BridgeDebugEvent,
92
94
  type HarnessV1BridgeOutboundMessage,
93
95
  type HarnessV1BridgeReady,
94
96
  type HarnessV1BridgeSandboxLog,
95
97
  type HarnessV1BridgeToolWire,
98
+ type Experimental_HarnessV1BridgeUserMessageResponse,
96
99
  } from './harness-v1-bridge-protocol';
97
100
  export {
98
101
  harnessV1DebugConfigSchema,