@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.
- package/CHANGELOG.md +29 -0
- package/README.md +5 -1
- package/dist/agent/index.d.ts +166 -118
- package/dist/agent/index.js +399 -102
- package/dist/agent/index.js.map +1 -1
- package/dist/bridge/index.d.ts +20 -1
- package/dist/bridge/index.js +29 -5
- package/dist/bridge/index.js.map +1 -1
- package/dist/index.d.ts +142 -102
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +8 -2
- package/dist/utils/index.js +339 -33
- package/dist/utils/index.js.map +1 -1
- package/package.json +5 -5
- package/src/agent/harness-agent-session.ts +115 -7
- package/src/agent/harness-agent-settings.ts +87 -7
- package/src/agent/harness-agent.ts +328 -112
- package/src/agent/internal/lifecycle-state-validation.ts +3 -0
- package/src/agent/internal/run-prompt.ts +49 -11
- package/src/agent/internal/strip-work-dir.ts +102 -0
- package/src/agent/internal/translate-stream-part.ts +5 -0
- package/src/bridge/index.ts +73 -6
- package/src/utils/index.ts +1 -0
- package/src/utils/write-skills.ts +419 -32
- package/src/v1/harness-v1-bridge-protocol.ts +6 -0
- package/src/v1/harness-v1-lifecycle-state.ts +45 -0
- package/src/v1/harness-v1-session.ts +3 -48
- package/src/v1/harness-v1-stream-part.ts +31 -1
- package/src/v1/index.ts +1 -0
|
@@ -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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
package/src/bridge/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
731
|
-
|
|
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
|
}
|