@ai-sdk/harness 1.0.39 → 1.0.40

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,10 +9,17 @@ import type {
9
9
  HarnessAgentSkill,
10
10
  } from './harness-agent-types';
11
11
  import type {
12
+ Arrayable,
13
+ Context,
12
14
  Experimental_SandboxSession as SandboxSession,
13
15
  ToolSet,
14
16
  } from '@ai-sdk/provider-utils';
15
- import type { ActiveTools, TelemetryOptions, ToolApprovalStatus } from 'ai';
17
+ import type {
18
+ ActiveTools,
19
+ StopCondition,
20
+ TelemetryOptions,
21
+ ToolApprovalStatus,
22
+ } from 'ai';
16
23
  import type { HarnessAllTools } from './harness-agent-tool-types';
17
24
 
18
25
  export type HarnessAgentToolApprovalConfiguration = Readonly<
@@ -91,6 +98,7 @@ type HarnessAgentToolFilteringSettings<TOOLS extends ToolSet> =
91
98
  export type HarnessAgentSettings<
92
99
  THarness extends HarnessAgentAdapter<any> = HarnessAgentAdapter,
93
100
  TUserTools extends ToolSet = {},
101
+ RUNTIME_CONTEXT extends Context = Context,
94
102
  > = {
95
103
  /**
96
104
  * The harness adapter driving the underlying agent runtime. Its
@@ -130,6 +138,22 @@ export type HarnessAgentSettings<
130
138
  */
131
139
  readonly instructions?: string;
132
140
 
141
+ /**
142
+ * Conditions that stop the current result after a completed harness tool
143
+ * step that can continue into another model step. The underlying turn remains
144
+ * unfinished and can be suspended and continued.
145
+ *
146
+ * A terminal text-only step finishes naturally and is not stopped early.
147
+ *
148
+ * When omitted, the harness runs until the turn naturally finishes or pauses.
149
+ */
150
+ readonly stopWhen?: Arrayable<
151
+ StopCondition<
152
+ NoInfer<HarnessAllTools<THarness, TUserTools>>,
153
+ RUNTIME_CONTEXT
154
+ >
155
+ >;
156
+
133
157
  /**
134
158
  * Built-in tool permission mode. Defaults to `'allow-all'`, preserving the
135
159
  * existing bypass-permissions behavior unless users opt in.
@@ -6,6 +6,7 @@ import type {
6
6
  HarnessV1SandboxProvider,
7
7
  } from '../v1';
8
8
  import {
9
+ asArray,
9
10
  asSchema,
10
11
  generateId,
11
12
  type Context,
@@ -19,6 +20,7 @@ import type {
19
20
  GenerateTextResult,
20
21
  ReasoningFileOutput,
21
22
  ReasoningOutput,
23
+ StopCondition,
22
24
  StreamTextResult,
23
25
  } from 'ai';
24
26
  import type {
@@ -133,7 +135,14 @@ export class HarnessAgent<
133
135
  */
134
136
  readonly tools: HarnessAllTools<THarness, TUserTools>;
135
137
 
136
- private readonly settings: HarnessAgentSettings<THarness, TUserTools>;
138
+ private readonly settings: HarnessAgentSettings<
139
+ THarness,
140
+ TUserTools,
141
+ RUNTIME_CONTEXT
142
+ >;
143
+ private readonly stopConditions: Array<
144
+ StopCondition<HarnessAllTools<THarness, TUserTools>, RUNTIME_CONTEXT>
145
+ >;
137
146
  private readonly sandboxConfig: HarnessAgentSandboxConfig;
138
147
  private readonly activeUserTools: TUserTools;
139
148
  private readonly builtinToolFiltering:
@@ -141,10 +150,14 @@ export class HarnessAgent<
141
150
  | undefined;
142
151
  private readonly permissionMode: HarnessAgentPermissionMode;
143
152
 
144
- constructor(settings: HarnessAgentSettings<THarness, TUserTools>) {
153
+ constructor(
154
+ settings: HarnessAgentSettings<THarness, TUserTools, RUNTIME_CONTEXT>,
155
+ ) {
145
156
  const sandboxConfig = resolveSandboxConfig(settings);
146
157
  validateSandboxBootstrapSettings(sandboxConfig);
147
158
  this.settings = settings;
159
+ this.stopConditions =
160
+ settings.stopWhen == null ? [] : asArray(settings.stopWhen);
148
161
  this.sandboxConfig = sandboxConfig;
149
162
  this.id = settings.id;
150
163
  const userTools = settings.tools ?? ({} as TUserTools);
@@ -528,6 +541,7 @@ export class HarnessAgent<
528
541
  runtimeContext: input.runtimeContext,
529
542
  abortSignal: input.abortSignal,
530
543
  telemetry: this.settings.telemetry,
544
+ stopConditions: this.stopConditions,
531
545
  toolApprovalContinuations: input.turnInput.toolApprovalContinuations,
532
546
  toolResultContinuations: input.turnInput.toolResultContinuations,
533
547
  });
@@ -546,6 +560,7 @@ export class HarnessAgent<
546
560
  runtimeContext: input.runtimeContext,
547
561
  abortSignal: input.abortSignal,
548
562
  telemetry: this.settings.telemetry,
563
+ stopConditions: this.stopConditions,
549
564
  });
550
565
  }
551
566
 
@@ -32,6 +32,7 @@ import type {
32
32
  ContentPart,
33
33
  ProviderMetadata,
34
34
  StepResult,
35
+ StopCondition,
35
36
  TelemetryOptions,
36
37
  TextStreamPart,
37
38
  } from 'ai';
@@ -41,9 +42,14 @@ import type { HarnessAgentToolApprovalConfiguration } from '../harness-agent-set
41
42
  import { HarnessStreamTextResult } from './harness-stream-text-result';
42
43
  import { translateStreamPart } from './translate-stream-part';
43
44
  import { stripWorkDir } from './strip-work-dir';
44
- import { createTurnTelemetry, type TurnContentPart } from './turn-telemetry';
45
+ import {
46
+ createTurnTelemetry,
47
+ type TurnContentPart,
48
+ type TurnTelemetry,
49
+ } from './turn-telemetry';
45
50
  import { resolveCustomToolApproval } from './permission-mode';
46
51
  import { logBridgeError } from '../../utils/bridge-diagnostics';
52
+ import { pinSandboxChannelEventCheckpoint } from '../../utils/sandbox-channel';
47
53
 
48
54
  /**
49
55
  * Drive one prompt turn end-to-end:
@@ -81,6 +87,7 @@ export function runPrompt<
81
87
  runtimeContext: RUNTIME_CONTEXT;
82
88
  abortSignal: AbortSignal | undefined;
83
89
  telemetry?: TelemetryOptions | undefined;
90
+ stopConditions?: ReadonlyArray<StopCondition<TOOLS, RUNTIME_CONTEXT>>;
84
91
  toolApproval?: HarnessAgentToolApprovalConfiguration | undefined;
85
92
  pendingToolApprovals?: readonly HarnessV1PendingToolApproval[];
86
93
  pendingToolResults?: readonly HarnessV1PendingToolResult[];
@@ -96,6 +103,7 @@ export function runPrompt<
96
103
  onToolResultSettled?: (toolCallId: string) => void;
97
104
  onTurnFinished?: () => void;
98
105
  onTurnFailed?: () => void;
106
+ onStopConditionMet?: () => Promise<void>;
99
107
  }): {
100
108
  result: HarnessStreamTextResult<TOOLS, RUNTIME_CONTEXT>;
101
109
  done: Promise<void>;
@@ -220,9 +228,21 @@ export function runPrompt<
220
228
  );
221
229
  const settledHostToolCallIds = new Set<string>();
222
230
  let closingResumedStep = false;
231
+ let pendingStopBoundary:
232
+ | {
233
+ finishReason: LanguageModelV4FinishReason;
234
+ usage: LanguageModelV4Usage;
235
+ releaseCheckpoint: (() => void) | undefined;
236
+ }
237
+ | undefined;
223
238
  let finalFinish:
224
239
  | Extract<HarnessV1StreamPart, { type: 'finish' }>
225
240
  | undefined;
241
+ const completedSteps: Array<StepResult<TOOLS, RUNTIME_CONTEXT>> = [];
242
+ const releasePendingStopBoundary = (): void => {
243
+ pendingStopBoundary?.releaseCheckpoint?.();
244
+ pendingStopBoundary = undefined;
245
+ };
226
246
 
227
247
  // Accumulate the model's output content per step so telemetry can record
228
248
  // `gen_ai.output.messages` and reporters can log what was actually said.
@@ -270,12 +290,14 @@ export function runPrompt<
270
290
  content: buildStepContent(),
271
291
  });
272
292
  resetStepContent();
273
- return result.finishStep({
293
+ const step = result.finishStep({
274
294
  finishReason: input.finishReason,
275
295
  usage: input.usage,
276
296
  providerMetadata: input.providerMetadata,
277
297
  warnings: [],
278
298
  });
299
+ completedSteps.push(step);
300
+ return step;
279
301
  };
280
302
  const finishForHostInputPause = async (options: {
281
303
  completeCurrentStep: boolean;
@@ -413,9 +435,16 @@ export function runPrompt<
413
435
  input: approval.input,
414
436
  } satisfies Extract<HarnessV1StreamPart, { type: 'tool-call' }>);
415
437
 
438
+ telemetry.start(input.session.modelId);
439
+ await telemetry.toolStart({
440
+ toolCallId: rawToolCall.toolCallId,
441
+ toolName: rawToolCall.toolName,
442
+ input: rawToolCall.input,
443
+ });
416
444
  const execution = await maybeExecuteHostTool({
417
445
  event: rawToolCall,
418
446
  tools: activeTools,
447
+ wrappedExecuteTool: telemetry.executeTool,
419
448
  sandboxSession: input.sandboxSession,
420
449
  abortSignal: input.abortSignal,
421
450
  control,
@@ -478,9 +507,35 @@ export function runPrompt<
478
507
 
479
508
  while (true) {
480
509
  const { value, done } = await reader.read();
481
- if (done) break;
510
+ if (done) {
511
+ releasePendingStopBoundary();
512
+ break;
513
+ }
482
514
  if (value == null) continue;
483
515
 
516
+ if (pendingStopBoundary != null) {
517
+ if (value.type === 'finish') {
518
+ releasePendingStopBoundary();
519
+ } else if (
520
+ (
521
+ await Promise.all(
522
+ input.stopConditions!.map(condition =>
523
+ condition({ steps: completedSteps }),
524
+ ),
525
+ )
526
+ ).some(Boolean)
527
+ ) {
528
+ await input.onStopConditionMet?.();
529
+ const { finishReason, usage } = pendingStopBoundary;
530
+ releasePendingStopBoundary();
531
+ telemetry.end({ finishReason, usage });
532
+ await result.finish();
533
+ return;
534
+ } else {
535
+ releasePendingStopBoundary();
536
+ }
537
+ }
538
+
484
539
  // Begin the operation span on stream-start, using the runtime-resolved
485
540
  // model the adapter reports (falling back to the session's model).
486
541
  if (value.type === 'stream-start') {
@@ -609,7 +664,7 @@ export function runPrompt<
609
664
  toolName: value.toolName,
610
665
  input: value.input,
611
666
  });
612
- telemetry.toolStart({
667
+ await telemetry.toolStart({
613
668
  toolCallId: value.toolCallId,
614
669
  toolName: value.toolName,
615
670
  input: value.input,
@@ -686,6 +741,13 @@ export function runPrompt<
686
741
  usage: value.usage,
687
742
  providerMetadata: value.harnessMetadata,
688
743
  });
744
+ if (input.stopConditions != null && input.stopConditions.length > 0) {
745
+ pendingStopBoundary = {
746
+ finishReason: value.finishReason,
747
+ usage: value.usage,
748
+ releaseCheckpoint: pinSandboxChannelEventCheckpoint(value),
749
+ };
750
+ }
689
751
  }
690
752
 
691
753
  if (value.type === 'finish') {
@@ -803,6 +865,7 @@ export function runPrompt<
803
865
  const execution = await maybeExecuteHostTool({
804
866
  event: toolCall,
805
867
  tools: activeTools,
868
+ wrappedExecuteTool: telemetry.executeTool,
806
869
  sandboxSession: input.sandboxSession,
807
870
  abortSignal: input.abortSignal,
808
871
  control,
@@ -869,6 +932,7 @@ export function runPrompt<
869
932
  });
870
933
  settleFailure(err);
871
934
  } finally {
935
+ releasePendingStopBoundary();
872
936
  reader.releaseLock();
873
937
  }
874
938
  })();
@@ -921,6 +985,7 @@ function hasTool(input: { tools: ToolSet; toolName: string }): boolean {
921
985
  async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
922
986
  event: { toolCallId: string; toolName: string; input: string };
923
987
  tools: TOOLS;
988
+ wrappedExecuteTool: TurnTelemetry['executeTool'];
924
989
  sandboxSession: SandboxSession;
925
990
  abortSignal: AbortSignal | undefined;
926
991
  control: HarnessV1PromptControl;
@@ -949,25 +1014,31 @@ async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
949
1014
  * back to the model — preliminary values are surfaced to the consumer
950
1015
  * stream alone, matching how the AI SDK treats `onPreliminaryToolResult`.
951
1016
  */
952
- let output: unknown;
953
- const stream = executeTool({
954
- tool,
955
- input: args as never,
956
- options: {
957
- toolCallId: input.event.toolCallId,
958
- messages: [],
959
- abortSignal: input.abortSignal,
960
- context: undefined as never,
961
- experimental_sandbox: input.sandboxSession,
1017
+ const output = await input.wrappedExecuteTool({
1018
+ toolCallId: input.event.toolCallId,
1019
+ execute: async () => {
1020
+ let output: unknown;
1021
+ const stream = executeTool({
1022
+ tool,
1023
+ input: args as never,
1024
+ options: {
1025
+ toolCallId: input.event.toolCallId,
1026
+ messages: [],
1027
+ abortSignal: input.abortSignal,
1028
+ context: undefined as never,
1029
+ experimental_sandbox: input.sandboxSession,
1030
+ },
1031
+ });
1032
+ for await (const part of stream) {
1033
+ if (part.type === 'preliminary') {
1034
+ input.onPreliminaryResult(part.output);
1035
+ } else {
1036
+ output = part.output;
1037
+ }
1038
+ }
1039
+ return output;
962
1040
  },
963
1041
  });
964
- for await (const part of stream) {
965
- if (part.type === 'preliminary') {
966
- input.onPreliminaryResult(part.output);
967
- } else {
968
- output = part.output;
969
- }
970
- }
971
1042
 
972
1043
  await input.control.submitToolResult({
973
1044
  toolCallId: input.event.toolCallId,
@@ -58,7 +58,12 @@ export interface TurnTelemetry {
58
58
  toolCallId: string;
59
59
  toolName: string;
60
60
  input: unknown;
61
- }): void;
61
+ }): void | Promise<void>;
62
+ /** Execute a host tool through each telemetry integration's context wrapper. */
63
+ executeTool<T>(input: {
64
+ toolCallId: string;
65
+ execute: () => PromiseLike<T>;
66
+ }): Promise<T>;
62
67
  /**
63
68
  * A tool execution completed (on its `tool-result` or after host execution).
64
69
  * Idempotent per `toolCallId` — the first caller wins, so provider-executed
@@ -78,7 +83,10 @@ const NOOP: TurnTelemetry = {
78
83
  start() {},
79
84
  ensureStepOpen() {},
80
85
  stepFinish() {},
81
- toolStart() {},
86
+ async toolStart() {},
87
+ async executeTool({ execute }) {
88
+ return await execute();
89
+ },
82
90
  toolEnd() {},
83
91
  end() {},
84
92
  error() {},
@@ -258,10 +266,11 @@ export function createTurnTelemetry(opts: {
258
266
  stepNumber += 1;
259
267
  },
260
268
 
261
- toolStart(call) {
269
+ async toolStart(call) {
262
270
  ensureStepOpen();
271
+ if (openTools.has(call.toolCallId)) return;
263
272
  openTools.set(call.toolCallId, call);
264
- dispatcher.onToolExecutionStart?.(
273
+ await dispatcher.onToolExecutionStart?.(
265
274
  cast<'onToolExecutionStart'>({
266
275
  callId,
267
276
  messages: [],
@@ -277,6 +286,11 @@ export function createTurnTelemetry(opts: {
277
286
  );
278
287
  },
279
288
 
289
+ async executeTool({ toolCallId, execute }) {
290
+ if (dispatcher.executeTool == null) return await execute();
291
+ return await dispatcher.executeTool({ callId, toolCallId, execute });
292
+ },
293
+
280
294
  toolEnd(toolCallId, output) {
281
295
  const call = openTools.get(toolCallId);
282
296
  if (call == null) return;
@@ -234,6 +234,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
234
234
  let currentBoundPort = 0;
235
235
  let currentTurnState: BridgeState = 'init';
236
236
  let activeSocket: WebSocket | undefined;
237
+ let activeSocketReadyForLiveEvents = false;
237
238
  let isFirstTurn = true;
238
239
  let turnAbort: AbortController | undefined;
239
240
  let currentUserMessages: string[] | undefined;
@@ -389,7 +390,10 @@ export async function runBridge<TStart extends { type: 'start' }>(
389
390
  eventLog.push({ seq, line });
390
391
  diskBuffer += `${line}\n`;
391
392
  scheduleEventFlush();
392
- if (activeSocket?.readyState === WS_OPEN) {
393
+ if (
394
+ activeSocketReadyForLiveEvents &&
395
+ activeSocket?.readyState === WS_OPEN
396
+ ) {
393
397
  try {
394
398
  activeSocket.send(line);
395
399
  } catch {
@@ -522,6 +526,8 @@ export async function runBridge<TStart extends { type: 'start' }>(
522
526
  ): Promise<void> => {
523
527
  switch (msg.type) {
524
528
  case 'start': {
529
+ if (activeSocket !== ws) return;
530
+ activeSocketReadyForLiveEvents = true;
525
531
  const firstTurn = isFirstTurn;
526
532
  isFirstTurn = false;
527
533
  eventLog = []; // clear previous turn; keep seqCounter monotonic
@@ -643,7 +649,9 @@ export async function runBridge<TStart extends { type: 'start' }>(
643
649
  }
644
650
  return;
645
651
  case 'resume':
652
+ if (activeSocket !== ws) return;
646
653
  replay(ws, msg.lastSeenEventId);
654
+ activeSocketReadyForLiveEvents = true;
647
655
  return;
648
656
  case 'shutdown':
649
657
  currentTurnState = 'done';
@@ -721,6 +729,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
721
729
  // (the host reconnecting after a drop). The previous socket's close is a
722
730
  // no-op below because it is no longer `activeSocket`.
723
731
  activeSocket = ws;
732
+ activeSocketReadyForLiveEvents = false;
724
733
 
725
734
  // Announce liveness the instant we accept. Some sandbox runtimes complete
726
735
  // the host-side WS handshake before the connection is forwarded here; the
@@ -754,6 +763,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
754
763
  // log for replay when the host reconnects.
755
764
  if (activeSocket === ws) {
756
765
  activeSocket = undefined;
766
+ activeSocketReadyForLiveEvents = false;
757
767
  }
758
768
  });
759
769
 
@@ -75,6 +75,30 @@ type Listener<TOut extends { type: string }, T extends EventTypeOf<TOut>> = (
75
75
  event: Extract<TOut, { type: T }>,
76
76
  ) => void;
77
77
 
78
+ /*
79
+ * The agent and utilities entrypoints bundle this module separately. A global
80
+ * symbol lets the agent recognize metadata attached by the channel's bundle
81
+ * copy, while the non-enumerable property leaves protocol payloads unchanged.
82
+ */
83
+ const sandboxChannelEventCheckpointSymbol = Symbol.for(
84
+ 'vercel.ai.harness.sandboxChannelEventCheckpoint',
85
+ );
86
+
87
+ type SandboxChannelEventCheckpoint = {
88
+ pin: () => () => void;
89
+ };
90
+
91
+ export function pinSandboxChannelEventCheckpoint(
92
+ event: unknown,
93
+ ): (() => void) | undefined {
94
+ if (event == null || typeof event !== 'object') return undefined;
95
+ return (
96
+ event as {
97
+ [sandboxChannelEventCheckpointSymbol]?: SandboxChannelEventCheckpoint;
98
+ }
99
+ )[sandboxChannelEventCheckpointSymbol]?.pin();
100
+ }
101
+
78
102
  const sleep = (ms: number): Promise<void> =>
79
103
  new Promise(resolve => {
80
104
  const t = setTimeout(resolve, ms);
@@ -136,6 +160,9 @@ export class SandboxChannel<
136
160
  * replayed to the next process on `resume`.
137
161
  */
138
162
  private suspended = false;
163
+ private pinnedSuspensionCursor:
164
+ | { eventId: number; token: object }
165
+ | undefined;
139
166
  /** Channel is fully torn down; `send` throws and `onClose` has fired. */
140
167
  private terminal = false;
141
168
  private _lastSeenEventId = 0;
@@ -251,6 +278,9 @@ export class SandboxChannel<
251
278
  }
252
279
 
253
280
  interrupt(options?: { timeoutMs?: number }): Promise<void> {
281
+ if (this.pinnedSuspensionCursor != null) {
282
+ return Promise.resolve();
283
+ }
254
284
  const timeoutMs = options?.timeoutMs ?? 5000;
255
285
  return new Promise<void>((resolve, reject) => {
256
286
  let settled = false;
@@ -313,19 +343,24 @@ export class SandboxChannel<
313
343
  * aborts it) and accumulates events past the cursor for the next process to
314
344
  * `resume`. Unlike {@link close}, the consumer's active turn is wound down
315
345
  * cleanly — adapters distinguish a suspend from an unexpected drop via the
316
- * `'suspended'` close reason and resolve `done` successfully.
346
+ * `'suspended'` close reason and resolve `done` successfully. When an event
347
+ * checkpoint is pinned, the returned cursor points to that event so any
348
+ * already-dispatched tail is replayed by the next process.
317
349
  */
318
350
  suspend(): Promise<number> {
319
351
  return new Promise<number>(resolve => {
352
+ const pinnedSuspensionCursor = this.pinnedSuspensionCursor?.eventId;
320
353
  if (this.terminal) {
321
- resolve(this._lastSeenEventId);
354
+ resolve(pinnedSuspensionCursor ?? this._lastSeenEventId);
322
355
  return;
323
356
  }
324
357
  // Stop counting/dispatching further inbound frames immediately, and
325
358
  // suppress reconnect so the socket close finalises.
326
359
  this.suspended = true;
327
360
  this.closing = true;
328
- this.onClose(() => resolve(this._lastSeenEventId));
361
+ this.onClose(() =>
362
+ resolve(pinnedSuspensionCursor ?? this._lastSeenEventId),
363
+ );
329
364
  // Queue the close behind any already-dispatched frames so everything
330
365
  // delivered to the consumer is reflected in the final cursor.
331
366
  this.enqueue(() => {
@@ -465,6 +500,9 @@ export class SandboxChannel<
465
500
  schema: this.outboundSchema,
466
501
  });
467
502
  if (validated.success) {
503
+ if (seq !== undefined) {
504
+ this.attachEventCheckpoint({ event: validated.value, eventId: seq });
505
+ }
468
506
  this.dispatch(validated.value);
469
507
  } else {
470
508
  this.dispatch({
@@ -506,6 +544,26 @@ export class SandboxChannel<
506
544
  }
507
545
  }
508
546
 
547
+ private attachEventCheckpoint(options: {
548
+ event: TOut;
549
+ eventId: number;
550
+ }): void {
551
+ if (!Object.isExtensible(options.event)) return;
552
+ Object.defineProperty(options.event, sandboxChannelEventCheckpointSymbol, {
553
+ value: {
554
+ pin: () => {
555
+ const token = {};
556
+ this.pinnedSuspensionCursor = { eventId: options.eventId, token };
557
+ return () => {
558
+ if (this.pinnedSuspensionCursor?.token === token) {
559
+ this.pinnedSuspensionCursor = undefined;
560
+ }
561
+ };
562
+ },
563
+ } satisfies SandboxChannelEventCheckpoint,
564
+ });
565
+ }
566
+
509
567
  private finalizeClose(code: number, reason: string): void {
510
568
  if (this.terminal) return;
511
569
  this.terminal = true;