@ai-sdk/harness 1.0.92 → 1.0.94

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 @@ import {
9
9
  type HarnessV1PromptControl,
10
10
  type HarnessV1ResponseFormat,
11
11
  type HarnessV1Session,
12
+ type HarnessV1Skill,
12
13
  type HarnessV1StreamPart,
13
14
  type HarnessV1ToolSpec,
14
15
  } from '../../v1';
@@ -43,7 +44,7 @@ import type { HarnessAgentToolResultContinuation } from '../harness-agent-tool-r
43
44
  import type { HarnessAgentToolApprovalConfiguration } from '../harness-agent-settings';
44
45
  import { HarnessStreamTextResult } from './harness-stream-text-result';
45
46
  import { translateStreamPart } from './translate-stream-part';
46
- import { stripWorkDir } from './strip-work-dir';
47
+ import { createToolInputWorkDirStripper, stripWorkDir } from './strip-work-dir';
47
48
  import {
48
49
  createTurnTelemetry,
49
50
  type TurnContentPart,
@@ -80,6 +81,8 @@ export function runPrompt<
80
81
  mode?: 'prompt' | 'continue';
81
82
  /** Required for `mode: 'prompt'`; absent for `mode: 'continue'`. */
82
83
  prompt?: HarnessV1Prompt;
84
+ model?: string;
85
+ skills?: ReadonlyArray<HarnessV1Skill>;
83
86
  instructions: string | undefined;
84
87
  tools: TOOLS;
85
88
  activeTools?: ToolSet;
@@ -139,7 +142,7 @@ export function runPrompt<
139
142
  const telemetry = createTurnTelemetry({
140
143
  telemetry: input.telemetry,
141
144
  harnessId: input.harness.harnessId,
142
- modelId: input.session.modelId,
145
+ modelId: input.model,
143
146
  instructions: input.instructions,
144
147
  promptText: input.prompt != null ? promptToText(input.prompt) : '',
145
148
  runtimeContext: input.runtimeContext,
@@ -179,6 +182,8 @@ export function runPrompt<
179
182
  input.mode === 'continue'
180
183
  ? emit =>
181
184
  input.session.doContinueTurn({
185
+ model: input.model,
186
+ skills: input.skills ?? [],
182
187
  responseFormat: input.responseFormat,
183
188
  tools: input.toolSpecs,
184
189
  instructions: input.instructions,
@@ -193,6 +198,8 @@ export function runPrompt<
193
198
  }
194
199
  return input.session.doPromptTurn({
195
200
  prompt: input.prompt,
201
+ model: input.model,
202
+ skills: input.skills ?? [],
196
203
  responseFormat: input.responseFormat,
197
204
  tools: input.toolSpecs,
198
205
  instructions: input.instructions,
@@ -216,6 +223,9 @@ export function runPrompt<
216
223
  const { stream, control } = bridge;
217
224
  input.onPromptControlAvailable?.(control);
218
225
  const reader = stream.getReader();
226
+ const stripToolInputWorkDir = createToolInputWorkDirStripper({
227
+ sessionWorkDir: input.sessionWorkDir,
228
+ });
219
229
  const toolCallsByToolCallId = new Map<string, ToolCallTextStreamPart>();
220
230
  const rawToolCallsByToolCallId = new Map<
221
231
  string,
@@ -530,7 +540,7 @@ export function runPrompt<
530
540
  input: approval.input,
531
541
  } satisfies Extract<HarnessV1StreamPart, { type: 'tool-call' }>);
532
542
 
533
- await telemetry.start(input.session.modelId);
543
+ await telemetry.start(input.model);
534
544
  await telemetry.toolStart({
535
545
  toolCallId: rawToolCall.toolCallId,
536
546
  toolName: rawToolCall.toolName,
@@ -645,9 +655,9 @@ export function runPrompt<
645
655
  }
646
656
 
647
657
  // Begin the operation span on stream-start, using the runtime-resolved
648
- // model the adapter reports (falling back to the session's model).
658
+ // model the adapter reports (falling back to the requested turn model).
649
659
  if (value.type === 'stream-start') {
650
- await telemetry.start(value.modelId ?? input.session.modelId);
660
+ await telemetry.start(value.modelId ?? input.model);
651
661
  }
652
662
 
653
663
  // Open a step span lazily before the first content of each step.
@@ -660,6 +670,28 @@ export function runPrompt<
660
670
  await telemetry.ensureStepOpen();
661
671
  }
662
672
 
673
+ if (
674
+ value.type === 'tool-input-start' ||
675
+ value.type === 'tool-input-delta' ||
676
+ value.type === 'tool-input-end'
677
+ ) {
678
+ if (
679
+ settledHostToolCallIds.has(value.id) ||
680
+ settledBuiltinApprovalToolCallIds.has(value.id)
681
+ ) {
682
+ continue;
683
+ }
684
+ for (const displayValue of stripToolInputWorkDir(value)) {
685
+ for (const part of translateStreamPart<TOOLS>(
686
+ displayValue,
687
+ translateOptions,
688
+ )) {
689
+ result.enqueue(part);
690
+ }
691
+ }
692
+ continue;
693
+ }
694
+
663
695
  /*
664
696
  * Strip the session working-directory prefix for everything the
665
697
  * consumer sees. The original `value` is kept intact for host tool
@@ -734,12 +766,18 @@ export function runPrompt<
734
766
  // paths help debugging); the consumer-facing settle uses the
735
767
  // workDir-stripped one, like every other forwarded part.
736
768
  await telemetry.error(value.error);
737
- logBridgeError({
738
- harnessId: input.harness.harnessId,
739
- sessionId: input.session.sessionId,
740
- context: 'harness stream error',
741
- error: value.error,
742
- });
769
+ // A turn the caller itself aborted ends with an error-shaped part by
770
+ // construction; diagnosing the caller's own signal to stderr reads
771
+ // as a malfunction. `settleFailure` below still reports it as an
772
+ // abort to the consumer.
773
+ if (!input.abortSignal?.aborted) {
774
+ logBridgeError({
775
+ harnessId: input.harness.harnessId,
776
+ sessionId: input.session.sessionId,
777
+ context: 'harness stream error',
778
+ error: value.error,
779
+ });
780
+ }
743
781
  settleFailure(displayValue.error);
744
782
  return;
745
783
  }
@@ -1,5 +1,57 @@
1
1
  import type { HarnessV1StreamPart } from '../../v1';
2
2
 
3
+ type ToolInputStreamPart = Extract<
4
+ HarnessV1StreamPart,
5
+ {
6
+ type: 'tool-input-start' | 'tool-input-delta' | 'tool-input-end';
7
+ }
8
+ >;
9
+
10
+ export function createToolInputWorkDirStripper({
11
+ sessionWorkDir,
12
+ }: {
13
+ sessionWorkDir: string;
14
+ }): (part: ToolInputStreamPart) => ToolInputStreamPart[] {
15
+ const pendingByToolCallId = new Map<string, string>();
16
+
17
+ return part => {
18
+ if (sessionWorkDir.length === 0) return [part];
19
+
20
+ if (part.type === 'tool-input-start') {
21
+ pendingByToolCallId.set(part.id, '');
22
+ return [part];
23
+ }
24
+
25
+ if (part.type === 'tool-input-delta') {
26
+ const stripped = stripStreamingString({
27
+ value: (pendingByToolCallId.get(part.id) ?? '') + part.delta,
28
+ workDir: sessionWorkDir,
29
+ final: false,
30
+ });
31
+ pendingByToolCallId.set(part.id, stripped.pending);
32
+ return stripped.output.length === 0
33
+ ? []
34
+ : [{ ...part, delta: stripped.output }];
35
+ }
36
+
37
+ const pending = pendingByToolCallId.get(part.id);
38
+ pendingByToolCallId.delete(part.id);
39
+ if (pending == null || pending.length === 0) return [part];
40
+
41
+ const stripped = stripStreamingString({
42
+ value: pending,
43
+ workDir: sessionWorkDir,
44
+ final: true,
45
+ });
46
+ return stripped.output.length === 0
47
+ ? [part]
48
+ : [
49
+ { type: 'tool-input-delta', id: part.id, delta: stripped.output },
50
+ part,
51
+ ];
52
+ };
53
+ }
54
+
3
55
  /**
4
56
  * Remove the session working-directory prefix from path-bearing fields of a
5
57
  * stream event, returning a new event for display to consumers.
@@ -23,6 +75,8 @@ export function stripWorkDir(
23
75
  if (sessionWorkDir.length === 0) return part;
24
76
 
25
77
  switch (part.type) {
78
+ case 'tool-input-delta':
79
+ return { ...part, delta: stripString(part.delta, sessionWorkDir) };
26
80
  case 'tool-call':
27
81
  return { ...part, input: stripString(part.input, sessionWorkDir) };
28
82
  case 'tool-result':
@@ -50,6 +104,54 @@ function stripString(value: string, workDir: string): string {
50
104
  return value.split(`${workDir}/`).join('').split(workDir).join('.');
51
105
  }
52
106
 
107
+ function stripStreamingString({
108
+ value,
109
+ workDir,
110
+ final,
111
+ }: {
112
+ value: string;
113
+ workDir: string;
114
+ final: boolean;
115
+ }): { output: string; pending: string } {
116
+ let remaining = value;
117
+ let output = '';
118
+
119
+ while (remaining.length > 0) {
120
+ const matchIndex = remaining.indexOf(workDir);
121
+ if (matchIndex >= 0) {
122
+ output += remaining.slice(0, matchIndex);
123
+ const followingIndex = matchIndex + workDir.length;
124
+ if (followingIndex === remaining.length && !final) {
125
+ return { output, pending: remaining.slice(matchIndex) };
126
+ }
127
+ if (remaining[followingIndex] === '/') {
128
+ remaining = remaining.slice(followingIndex + 1);
129
+ } else {
130
+ output += '.';
131
+ remaining = remaining.slice(followingIndex);
132
+ }
133
+ continue;
134
+ }
135
+
136
+ if (final) return { output: output + remaining, pending: '' };
137
+
138
+ let pendingLength = Math.min(remaining.length, workDir.length - 1);
139
+ while (
140
+ pendingLength > 0 &&
141
+ !workDir.startsWith(remaining.slice(-pendingLength))
142
+ ) {
143
+ pendingLength -= 1;
144
+ }
145
+ const outputLength = remaining.length - pendingLength;
146
+ return {
147
+ output: output + remaining.slice(0, outputLength),
148
+ pending: remaining.slice(outputLength),
149
+ };
150
+ }
151
+
152
+ return { output, pending: '' };
153
+ }
154
+
53
155
  /**
54
156
  * Recursively strip the working directory from every string nested in an
55
157
  * arbitrary JSON-like value. Non-string leaves are returned unchanged.
@@ -103,6 +103,11 @@ export function translateStreamPart<TOOLS extends ToolSet>(
103
103
  } as TextStreamPart<TOOLS>,
104
104
  ];
105
105
 
106
+ case 'tool-input-start':
107
+ case 'tool-input-delta':
108
+ case 'tool-input-end':
109
+ return [event as TextStreamPart<TOOLS>];
110
+
106
111
  case 'tool-call':
107
112
  // Tool-call validation is async (it parses input against the tool's
108
113
  // schema) and lives in `run-prompt.ts` where the merged tool set is in
@@ -293,8 +293,27 @@ export interface RunBridgeOptions<TStart extends { type: 'start' }> {
293
293
  bridgeType: string;
294
294
  /** Directory for `bridge-meta.json` / `start-config.json`. Created if absent. */
295
295
  bridgeStateDir: string;
296
- /** Drive one prompt turn. Rejections surface to the host as an `error` event. */
296
+ /**
297
+ * Drive one prompt turn. Rejections surface to the host as an `error`
298
+ * event.
299
+ *
300
+ * Contract: once `turn.abortSignal` fires, wind down promptly — turns are
301
+ * serialized, and a replacement `start` waits up to
302
+ * {@link turnTeardownGraceMs} for this promise to settle before it
303
+ * proceeds anyway.
304
+ */
297
305
  onStart(start: TStart, turn: BridgeTurn): Promise<void>;
306
+ /**
307
+ * How long a replacement `start` waits for the previous turn's teardown
308
+ * after aborting it, in milliseconds. Turns are serialized so an aborted
309
+ * turn cannot emit into its replacement's event log or overlap its runtime
310
+ * process — but only within this bound: an adapter that does not settle
311
+ * `onStart` after its abort signal fires forfeits the protection for that
312
+ * boundary, and the new turn proceeds anyway rather than blocking forever.
313
+ * The default of ten seconds exceeds the Claude bridge's five-second
314
+ * hard-abort fallback.
315
+ */
316
+ turnTeardownGraceMs?: number;
298
317
  /**
299
318
  * Produce the adapter-defined runtime resume data for `stop`. Defaults to
300
319
  * `{}`.
@@ -355,6 +374,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
355
374
  options: RunBridgeOptions<TStart>,
356
375
  ): Promise<BridgeHandle> {
357
376
  const { bridgeType, bridgeStateDir, onStart, onStop, onDestroy } = options;
377
+ const teardownGraceMs = options.turnTeardownGraceMs ?? 10_000;
358
378
  const expectedToken = options.token ?? procEnv.BRIDGE_CHANNEL_TOKEN ?? '';
359
379
  const bridgeWsPort =
360
380
  options.port ?? parseInt(procEnv.BRIDGE_WS_PORT ?? '0', 10);
@@ -384,6 +404,12 @@ export async function runBridge<TStart extends { type: 'start' }>(
384
404
  let isFirstTurn = true;
385
405
  let turnAbort: AbortController | undefined;
386
406
  let currentUserMessages: InternalBridgeUserMessageQueue | undefined;
407
+ /**
408
+ * Settles when the in-flight turn has fully wound down — `onStart`
409
+ * returned or threw AND its completion state was recorded. `undefined`
410
+ * between turns. A new `start` fences on this so turns never overlap.
411
+ */
412
+ let activeTurn: Promise<void> | undefined;
387
413
 
388
414
  // Diagnostics. Resolved per turn from `start.debug` with a sandbox-side
389
415
  // env fallback; gates console capture + structured `debug-event`s.
@@ -658,10 +684,44 @@ export async function runBridge<TStart extends { type: 'start' }>(
658
684
  ): Promise<void> => {
659
685
  switch (msg.type) {
660
686
  case 'start': {
687
+ /*
688
+ * A new turn replaces the active one — but only after the active one
689
+ * has fully wound down. Inbound frames are dispatched concurrently,
690
+ * and the host settles a caller abort immediately, so a retry's
691
+ * `start` can arrive while the aborted turn is still tearing down
692
+ * (e.g. a graceful interrupt). Without this fence the old turn would
693
+ * keep emitting into the new turn's cleared event log, two runtime
694
+ * processes would run side by side, and the old turn's completion
695
+ * would mark the bridge `waiting` underneath the new turn. Abort the
696
+ * old turn to hasten its teardown; adapters are expected to bound
697
+ * that teardown themselves (e.g. a hard-abort fallback), but the
698
+ * runtime does not rely on it: the wait is capped by the teardown
699
+ * grace period, after which the new turn proceeds anyway — the
700
+ * pre-fence overlapping behavior — rather than hanging behind a
701
+ * teardown that never settles.
702
+ */
703
+ for (;;) {
704
+ const pendingTurn = activeTurn;
705
+ if (pendingTurn == null) break;
706
+ turnAbort?.abort();
707
+ currentUserMessages?.close(
708
+ new Error('A new bridge turn replaced the active turn.'),
709
+ );
710
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
711
+ const settled = await Promise.race([
712
+ pendingTurn.then(() => true as const),
713
+ new Promise<false>(resolve => {
714
+ graceTimer = setTimeout(() => resolve(false), teardownGraceMs);
715
+ graceTimer.unref?.();
716
+ }),
717
+ ]);
718
+ clearTimeout(graceTimer);
719
+ if (!settled) break;
720
+ }
721
+ let turnFinished!: () => void;
722
+ const thisTurn = new Promise<void>(resolve => (turnFinished = resolve));
723
+ activeTurn = thisTurn;
661
724
  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
- );
665
725
  const firstTurn = isFirstTurn;
666
726
  isFirstTurn = false;
667
727
  eventLog = []; // clear previous turn; keep seqCounter monotonic
@@ -727,8 +787,15 @@ export async function runBridge<TStart extends { type: 'start' }>(
727
787
  if (currentUserMessages === userMessages) {
728
788
  currentUserMessages = undefined;
729
789
  }
730
- currentTurnState = 'waiting';
731
- void writeBridgeMeta('waiting');
790
+ // Only the still-active turn records completion: after a fence
791
+ // timeout a replacement turn is already running, and this stale
792
+ // completion must not mark the bridge waiting underneath it.
793
+ if (activeTurn === thisTurn) {
794
+ activeTurn = undefined;
795
+ currentTurnState = 'waiting';
796
+ void writeBridgeMeta('waiting');
797
+ }
798
+ turnFinished();
732
799
  }
733
800
  return;
734
801
  }
@@ -30,6 +30,7 @@ export {
30
30
  writeSkills,
31
31
  type SkillFilePathMode,
32
32
  type WriteSkillsOptions,
33
+ type WriteSkillsResult,
33
34
  } from './write-skills';
34
35
  export {
35
36
  markBridgeStarting,