@ai-sdk/harness 1.0.122 → 1.0.124
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 +27 -0
- package/dist/agent/index.d.ts +5 -5
- package/dist/agent/index.js +273 -64
- package/dist/agent/index.js.map +1 -1
- package/dist/bridge/index.js +0 -1
- package/dist/bridge/index.js.map +1 -1
- package/dist/index.d.ts +28 -6
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +30 -5
- package/dist/utils/index.js +130 -16
- package/dist/utils/index.js.map +1 -1
- package/package.json +4 -4
- package/src/agent/harness-agent.ts +32 -12
- package/src/agent/internal/bootstrap-recipe.ts +16 -16
- package/src/agent/internal/harness-stream-text-result.ts +1 -3
- package/src/agent/internal/run-prompt.ts +134 -19
- package/src/agent/internal/sandbox-bootstrap.ts +24 -10
- package/src/agent/internal/strip-work-dir.ts +57 -0
- package/src/agent/prepare-harness-sandbox-template.ts +16 -3
- package/src/agent/prepare-sandbox-for-harness.ts +14 -5
- package/src/bridge/index.ts +0 -1
- package/src/utils/sandbox-channel.ts +172 -19
- package/src/utils/write-instructions.ts +1 -1
- package/src/utils/write-skills.ts +1 -1
- package/src/v1/harness-session-data-directory-path.ts +28 -0
- package/src/v1/harness-v1-bootstrap.ts +5 -5
- package/src/v1/harness-v1-network-sandbox-session.ts +24 -0
- package/src/v1/index.ts +2 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
|
|
2
|
+
import { harnessStateDirectoryPath } from '../v1';
|
|
2
3
|
import type { HarnessAgentSandboxConfig } from './harness-agent-settings';
|
|
3
4
|
import type { HarnessAgentAdapter } from './harness-agent-types';
|
|
4
5
|
import { resolveSandboxDefaultWorkingDirectory } from '../utils/resolve-sandbox-default-working-directory';
|
|
6
|
+
import { resolveSandboxHomeDir } from '../utils/sandbox-home-dir';
|
|
5
7
|
import {
|
|
6
8
|
applyBootstrapRecipe,
|
|
7
9
|
hashHarnessBootstrap,
|
|
@@ -67,7 +69,7 @@ export async function prepareSandboxForHarness(options: {
|
|
|
67
69
|
: normalizeSandboxWorkDir(sandboxConfig.workDir);
|
|
68
70
|
const recipeIdentities: Record<string, string> = {};
|
|
69
71
|
const skippedHarnessIds: string[] = [];
|
|
70
|
-
let
|
|
72
|
+
let stateDirectory: string | undefined;
|
|
71
73
|
|
|
72
74
|
for (const harness of harnesses) {
|
|
73
75
|
const recipe = await harness.getBootstrap?.({
|
|
@@ -80,20 +82,27 @@ export async function prepareSandboxForHarness(options: {
|
|
|
80
82
|
|
|
81
83
|
const recipeIdentity = await hashHarnessBootstrap(recipe);
|
|
82
84
|
recipeIdentities[harness.harnessId] = recipeIdentity;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
// Harness infrastructure always lives under the sandbox's own HOME,
|
|
86
|
+
// never the working directory.
|
|
87
|
+
stateDirectory ??= harnessStateDirectoryPath({
|
|
88
|
+
sandboxHomeDir: await resolveSandboxHomeDir({
|
|
89
|
+
sandbox: options.session,
|
|
90
|
+
abortSignal: options.abortSignal,
|
|
91
|
+
}),
|
|
86
92
|
});
|
|
87
93
|
await applyBootstrapRecipe({
|
|
88
94
|
session: options.session,
|
|
89
95
|
recipe,
|
|
90
96
|
identity: recipeIdentity,
|
|
91
|
-
|
|
97
|
+
stateDirectory,
|
|
92
98
|
abortSignal: options.abortSignal,
|
|
93
99
|
});
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
if (sandboxConfig.onBootstrap != null) {
|
|
103
|
+
const defaultWorkingDirectory = await resolveSandboxDefaultWorkingDirectory(
|
|
104
|
+
{ sandboxSession: options.session, abortSignal: options.abortSignal },
|
|
105
|
+
);
|
|
97
106
|
await runSandboxBootstrap({
|
|
98
107
|
session: options.session,
|
|
99
108
|
workDir,
|
package/src/bridge/index.ts
CHANGED
|
@@ -83,6 +83,11 @@ type Listener<TOut extends { type: string }, T extends EventTypeOf<TOut>> = (
|
|
|
83
83
|
event: Extract<TOut, { type: T }>,
|
|
84
84
|
) => void;
|
|
85
85
|
|
|
86
|
+
type BufferedEvent<TOut extends { type: string }> = {
|
|
87
|
+
message: TOut;
|
|
88
|
+
listeners?: ReadonlyArray<Listener<TOut, EventTypeOf<TOut>>>;
|
|
89
|
+
};
|
|
90
|
+
|
|
86
91
|
/*
|
|
87
92
|
* The agent and utilities entrypoints bundle this module separately. A global
|
|
88
93
|
* symbol lets the agent recognize metadata attached by the channel's bundle
|
|
@@ -172,11 +177,18 @@ async function awaitWebSocketConnection({
|
|
|
172
177
|
/**
|
|
173
178
|
* Host-side typed wrapper around the bridge WebSocket connection.
|
|
174
179
|
*
|
|
175
|
-
* Buffers inbound messages
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
+
* Buffers inbound messages in arrival order while listeners attach, so callers
|
|
181
|
+
* do not miss or reorder early frames. Listener registration drains the
|
|
182
|
+
* ordered prefix synchronously. Selective listeners are given through the
|
|
183
|
+
* current task (including its microtasks) to attach before unhandled event
|
|
184
|
+
* types are retained independently, so they cannot block subscribed event
|
|
185
|
+
* types indefinitely. A listener that claims an already-buffered event still
|
|
186
|
+
* receives it if it unsubscribes before that ordered drain completes. Use
|
|
187
|
+
* {@link SandboxChannel.beginListenerAttachment} to define an explicit
|
|
188
|
+
* attachment boundary that spans asynchronous work. Inbound dispatch is
|
|
189
|
+
* serialised through a promise chain so a `close` event that arrives on the
|
|
190
|
+
* same microtask as the final `finish` message does not fire close handlers
|
|
191
|
+
* until the message has been dispatched.
|
|
180
192
|
*
|
|
181
193
|
* Survives transient disconnects. The bridge keeps running and
|
|
182
194
|
* accumulates events in an in-memory log keyed by a monotonic `seq`; on an
|
|
@@ -193,7 +205,12 @@ export class SandboxChannel<
|
|
|
193
205
|
EventTypeOf<TOut>,
|
|
194
206
|
Set<Listener<TOut, EventTypeOf<TOut>>>
|
|
195
207
|
>();
|
|
196
|
-
private readonly buffered
|
|
208
|
+
private readonly buffered: BufferedEvent<TOut>[] = [];
|
|
209
|
+
private readonly bufferedByType = new Map<EventTypeOf<TOut>, TOut[]>();
|
|
210
|
+
private bufferedOffset = 0;
|
|
211
|
+
private flushingBuffered = false;
|
|
212
|
+
private bufferedFlushScheduled = false;
|
|
213
|
+
private listenerAttachmentDepth = 0;
|
|
197
214
|
private readonly onCloseHandlers = new Set<
|
|
198
215
|
(code: number, reason: string) => void
|
|
199
216
|
>();
|
|
@@ -309,19 +326,46 @@ export class SandboxChannel<
|
|
|
309
326
|
}
|
|
310
327
|
set.add(listener as unknown as Listener<TOut, EventTypeOf<TOut>>);
|
|
311
328
|
|
|
312
|
-
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
329
|
+
this.captureBufferedListeners(type);
|
|
330
|
+
if (this.listenerAttachmentDepth > 0) {
|
|
331
|
+
return () => {
|
|
332
|
+
set!.delete(listener as unknown as Listener<TOut, EventTypeOf<TOut>>);
|
|
333
|
+
};
|
|
318
334
|
}
|
|
319
335
|
|
|
336
|
+
this.flushBufferedType(type);
|
|
337
|
+
this.flushBuffered();
|
|
338
|
+
this.scheduleSelectiveBufferedFlush();
|
|
339
|
+
|
|
320
340
|
return () => {
|
|
321
341
|
set!.delete(listener as unknown as Listener<TOut, EventTypeOf<TOut>>);
|
|
322
342
|
};
|
|
323
343
|
}
|
|
324
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Hold buffered event delivery while a consumer attaches a related set of
|
|
347
|
+
* listeners, including across asynchronous boundaries. Call the returned
|
|
348
|
+
* function once registration is complete. Buffered events are then replayed
|
|
349
|
+
* in arrival order; event types that remain unhandled are retained
|
|
350
|
+
* independently so they do not block subscribed types.
|
|
351
|
+
*/
|
|
352
|
+
beginListenerAttachment(): () => void {
|
|
353
|
+
this.listenerAttachmentDepth++;
|
|
354
|
+
let finished = false;
|
|
355
|
+
return () => {
|
|
356
|
+
if (finished) return;
|
|
357
|
+
finished = true;
|
|
358
|
+
this.listenerAttachmentDepth--;
|
|
359
|
+
if (this.listenerAttachmentDepth > 0) return;
|
|
360
|
+
|
|
361
|
+
for (const type of this.listeners.keys()) {
|
|
362
|
+
this.flushBufferedType(type);
|
|
363
|
+
}
|
|
364
|
+
this.flushBuffered();
|
|
365
|
+
this.scheduleSelectiveBufferedFlush();
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
325
369
|
onClose(handler: (code: number, reason: string) => void): void {
|
|
326
370
|
this.onCloseHandlers.add(handler);
|
|
327
371
|
}
|
|
@@ -645,13 +689,18 @@ export class SandboxChannel<
|
|
|
645
689
|
}
|
|
646
690
|
const type = message.type as EventTypeOf<TOut>;
|
|
647
691
|
const set = this.listeners.get(type);
|
|
648
|
-
if (
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
692
|
+
if (
|
|
693
|
+
this.listenerAttachmentDepth > 0 ||
|
|
694
|
+
this.bufferedOffset < this.buffered.length ||
|
|
695
|
+
!set ||
|
|
696
|
+
set.size === 0
|
|
697
|
+
) {
|
|
698
|
+
this.buffered.push({
|
|
699
|
+
message,
|
|
700
|
+
...(set != null && set.size > 0 ? { listeners: Array.from(set) } : {}),
|
|
701
|
+
});
|
|
702
|
+
this.flushBuffered();
|
|
703
|
+
this.scheduleSelectiveBufferedFlush();
|
|
655
704
|
return;
|
|
656
705
|
}
|
|
657
706
|
for (const listener of set) {
|
|
@@ -659,6 +708,110 @@ export class SandboxChannel<
|
|
|
659
708
|
}
|
|
660
709
|
}
|
|
661
710
|
|
|
711
|
+
private flushBuffered({
|
|
712
|
+
selective = false,
|
|
713
|
+
}: { selective?: boolean } = {}): void {
|
|
714
|
+
if (this.flushingBuffered || this.listenerAttachmentDepth > 0) return;
|
|
715
|
+
this.flushingBuffered = true;
|
|
716
|
+
try {
|
|
717
|
+
while (this.bufferedOffset < this.buffered.length) {
|
|
718
|
+
const bufferedEvent = this.buffered[this.bufferedOffset];
|
|
719
|
+
const message = bufferedEvent.message;
|
|
720
|
+
const type = message.type as EventTypeOf<TOut>;
|
|
721
|
+
const set = this.listeners.get(type);
|
|
722
|
+
const listeners =
|
|
723
|
+
bufferedEvent.listeners ??
|
|
724
|
+
(set != null && set.size > 0 ? Array.from(set) : undefined);
|
|
725
|
+
if (!listeners || listeners.length === 0) {
|
|
726
|
+
if (!selective) return;
|
|
727
|
+
this.bufferedOffset++;
|
|
728
|
+
const buffered = this.bufferedByType.get(type);
|
|
729
|
+
if (buffered) {
|
|
730
|
+
buffered.push(message);
|
|
731
|
+
} else {
|
|
732
|
+
this.bufferedByType.set(type, [message]);
|
|
733
|
+
}
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
this.bufferedOffset++;
|
|
738
|
+
for (const listener of listeners) {
|
|
739
|
+
listener(message as Extract<TOut, { type: EventTypeOf<TOut> }>);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
} finally {
|
|
743
|
+
if (this.bufferedOffset > 0) {
|
|
744
|
+
this.buffered.splice(0, this.bufferedOffset);
|
|
745
|
+
this.bufferedOffset = 0;
|
|
746
|
+
}
|
|
747
|
+
this.flushingBuffered = false;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private captureBufferedListeners(type: EventTypeOf<TOut>): void {
|
|
752
|
+
const set = this.listeners.get(type);
|
|
753
|
+
if (!set || set.size === 0) return;
|
|
754
|
+
|
|
755
|
+
for (let i = this.bufferedOffset; i < this.buffered.length; i++) {
|
|
756
|
+
const bufferedEvent = this.buffered[i];
|
|
757
|
+
if (
|
|
758
|
+
bufferedEvent.listeners == null &&
|
|
759
|
+
bufferedEvent.message.type === type
|
|
760
|
+
) {
|
|
761
|
+
bufferedEvent.listeners = Array.from(set);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private flushBufferedType(type: EventTypeOf<TOut>): void {
|
|
767
|
+
const buffered = this.bufferedByType.get(type);
|
|
768
|
+
if (!buffered) return;
|
|
769
|
+
|
|
770
|
+
let offset = 0;
|
|
771
|
+
try {
|
|
772
|
+
while (offset < buffered.length) {
|
|
773
|
+
const set = this.listeners.get(type);
|
|
774
|
+
if (!set || set.size === 0) return;
|
|
775
|
+
|
|
776
|
+
const message = buffered[offset++];
|
|
777
|
+
for (const listener of set) {
|
|
778
|
+
listener(message as Extract<TOut, { type: EventTypeOf<TOut> }>);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
} finally {
|
|
782
|
+
if (offset === buffered.length) {
|
|
783
|
+
this.bufferedByType.delete(type);
|
|
784
|
+
} else if (offset > 0) {
|
|
785
|
+
buffered.splice(0, offset);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private scheduleSelectiveBufferedFlush(): void {
|
|
791
|
+
if (
|
|
792
|
+
this.bufferedFlushScheduled ||
|
|
793
|
+
this.bufferedOffset >= this.buffered.length ||
|
|
794
|
+
!this.hasListeners() ||
|
|
795
|
+
this.listenerAttachmentDepth > 0
|
|
796
|
+
) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
this.bufferedFlushScheduled = true;
|
|
801
|
+
setTimeout(() => {
|
|
802
|
+
this.bufferedFlushScheduled = false;
|
|
803
|
+
if (this.listenerAttachmentDepth > 0) return;
|
|
804
|
+
this.flushBuffered({ selective: true });
|
|
805
|
+
}, 0);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private hasListeners(): boolean {
|
|
809
|
+
for (const listeners of this.listeners.values()) {
|
|
810
|
+
if (listeners.size > 0) return true;
|
|
811
|
+
}
|
|
812
|
+
return false;
|
|
813
|
+
}
|
|
814
|
+
|
|
662
815
|
private attachEventCheckpoint(options: {
|
|
663
816
|
event: TOut;
|
|
664
817
|
eventId: number;
|
|
@@ -207,7 +207,7 @@ function resolveInstructionsFilePath({
|
|
|
207
207
|
);
|
|
208
208
|
}
|
|
209
209
|
const containsTraversal = instructionsFile
|
|
210
|
-
.split(/[
|
|
210
|
+
.split(/[\\/]/)
|
|
211
211
|
.some(segment => segment === '..');
|
|
212
212
|
const normalizedInstructionsFile = path.posix.normalize(
|
|
213
213
|
instructionsFile.trim(),
|
|
@@ -551,7 +551,7 @@ function resolveSkillsRootDir({
|
|
|
551
551
|
);
|
|
552
552
|
}
|
|
553
553
|
const containsTraversal = skillsDir
|
|
554
|
-
.split(/[
|
|
554
|
+
.split(/[\\/]/)
|
|
555
555
|
.some(segment => segment === '..');
|
|
556
556
|
const normalizedSkillsDir = path.posix.normalize(skillsDir.trim());
|
|
557
557
|
const normalizedNoTrailingSlash = normalizedSkillsDir.replace(/\/+$/, '');
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export function encodeHarnessPathSegment(value: string): string {
|
|
4
|
+
const encoded = encodeURIComponent(value);
|
|
5
|
+
if (encoded === '') return '%';
|
|
6
|
+
if (encoded === '.') return '%2E';
|
|
7
|
+
if (encoded === '..') return '%2E%2E';
|
|
8
|
+
return encoded;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Return the per-session harness state path under `.agent-runs`. Session IDs
|
|
13
|
+
* occupy one encoded path segment, so path separators and parent-directory
|
|
14
|
+
* references cannot move session data outside the harness state directory.
|
|
15
|
+
*/
|
|
16
|
+
export function harnessSessionDataDirectoryPath({
|
|
17
|
+
stateDirectory,
|
|
18
|
+
sessionId,
|
|
19
|
+
}: {
|
|
20
|
+
stateDirectory: string;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
}): string {
|
|
23
|
+
return posix.join(
|
|
24
|
+
stateDirectory,
|
|
25
|
+
'.agent-runs',
|
|
26
|
+
encodeHarnessPathSegment(sessionId),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One file to write into the sandbox as part of an adapter's bootstrap recipe.
|
|
3
3
|
* Absolute paths are used as-is. Relative paths are resolved against the
|
|
4
|
-
*
|
|
4
|
+
* harness state directory (`$HOME/.ai-sdk-harness`). Paths should live under
|
|
5
5
|
* {@link HarnessV1Bootstrap.bootstrapDir}.
|
|
6
6
|
*/
|
|
7
7
|
export interface HarnessV1BootstrapFile {
|
|
@@ -33,10 +33,10 @@ export interface HarnessV1Bootstrap {
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Path inside the sandbox where this recipe writes its state. Absolute paths
|
|
36
|
-
* are used as-is. Relative paths are resolved against the
|
|
37
|
-
*
|
|
38
|
-
* declared in {@link files} should also use this prefix so an
|
|
39
|
-
* can sweep stale state by clearing the directory.
|
|
36
|
+
* are used as-is. Relative paths are resolved against the harness state
|
|
37
|
+
* directory (`$HOME/.ai-sdk-harness`). The marker file lives directly under
|
|
38
|
+
* it. Files declared in {@link files} should also use this prefix so an
|
|
39
|
+
* adapter upgrade can sweep stale state by clearing the directory.
|
|
40
40
|
*/
|
|
41
41
|
readonly bootstrapDir: string;
|
|
42
42
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
1
2
|
import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -9,6 +10,29 @@ export type HarnessV1PortEndpoint = {
|
|
|
9
10
|
readonly headers?: Readonly<Record<string, string>>;
|
|
10
11
|
};
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Fixed directory, relative to the sandbox's own HOME, that holds every
|
|
15
|
+
* piece of state the harness machinery generates.
|
|
16
|
+
*/
|
|
17
|
+
const HARNESS_V1_STATE_DIRECTORY_NAME = '.ai-sdk-harness';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fixed, non-configurable path for harness-generated state (bootstrap files,
|
|
21
|
+
* markers, and `.agent-runs`) under the sandbox's own HOME, never under
|
|
22
|
+
* {@link HarnessV1NetworkSandboxSession.defaultWorkingDirectory}. Resolve HOME
|
|
23
|
+
* with `resolveSandboxHomeDir` when it is not already known. This works from
|
|
24
|
+
* provider creation hooks that only have a plain sandbox session. The
|
|
25
|
+
* framework also calls this with a symbolic `$HOME` when hashing bootstrap
|
|
26
|
+
* recipes, so changes to the state path invalidate existing templates.
|
|
27
|
+
*/
|
|
28
|
+
export function harnessStateDirectoryPath({
|
|
29
|
+
sandboxHomeDir,
|
|
30
|
+
}: {
|
|
31
|
+
sandboxHomeDir: string;
|
|
32
|
+
}): string {
|
|
33
|
+
return posix.join(sandboxHomeDir, HARNESS_V1_STATE_DIRECTORY_NAME);
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
/**
|
|
13
37
|
* Network sandbox session returned by `HarnessV1SandboxProvider.createSession()`. The
|
|
14
38
|
* harness keeps this for the lifetime of a session. It is itself a
|
package/src/v1/index.ts
CHANGED
|
@@ -63,6 +63,8 @@ export type {
|
|
|
63
63
|
HarnessV1RequestTransformation,
|
|
64
64
|
HarnessV1RequestTransformationSources,
|
|
65
65
|
} from './harness-v1-network-sandbox-session';
|
|
66
|
+
export { harnessStateDirectoryPath } from './harness-v1-network-sandbox-session';
|
|
67
|
+
export { harnessSessionDataDirectoryPath } from './harness-session-data-directory-path';
|
|
66
68
|
export type { HarnessV1Skill } from './harness-v1-skill';
|
|
67
69
|
export type { HarnessV1StreamPart } from './harness-v1-stream-part';
|
|
68
70
|
export {
|