@ai-sdk/harness-pi 1.0.101 → 1.0.103
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 +16 -0
- package/dist/index.js +104 -20
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/pi-workspace-mirror.ts +157 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/harness-pi",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.103",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@ai-sdk/harness": "1.0.
|
|
29
|
+
"@ai-sdk/harness": "1.0.101",
|
|
30
30
|
"@ai-sdk/provider-utils": "5.0.36",
|
|
31
31
|
"@earendil-works/pi-ai": "0.74.2",
|
|
32
32
|
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"zod": "^3.25.76 || ^4.1.8"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@ai-sdk/sandbox-just-bash": "1.0.
|
|
40
|
+
"@ai-sdk/sandbox-just-bash": "1.0.101",
|
|
41
41
|
"@types/node": "22.19.19",
|
|
42
42
|
"@vercel/ai-tsconfig": "0.0.0",
|
|
43
43
|
"tsup": "^8.5.1",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
writeFile,
|
|
8
8
|
} from 'node:fs/promises';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
+
import { gunzipSync } from 'node:zlib';
|
|
10
11
|
import { shellQuote } from '@ai-sdk/harness/utils';
|
|
11
12
|
import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
|
|
12
13
|
|
|
@@ -32,6 +33,21 @@ import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
|
|
|
32
33
|
const PI_CONFIG_DIRS = ['.pi', '.agents'] as const;
|
|
33
34
|
const PI_CONTEXT_FILENAMES = ['AGENTS.md', 'AGENTS.MD'] as const;
|
|
34
35
|
|
|
36
|
+
/*
|
|
37
|
+
* The mirror runs on session start and again on every turn, so its cost must
|
|
38
|
+
* not scale with the number of files in scope. Reading one file per
|
|
39
|
+
* `readBinaryFile` call turns a `.agents/skills` tree of a few thousand
|
|
40
|
+
* `SKILL.md` files into a few thousand sequential round trips per turn, which
|
|
41
|
+
* exhausts the request budget of any sandbox whose filesystem calls are network
|
|
42
|
+
* calls: the report behind this code saw `429 Rate limit exceeded` from a
|
|
43
|
+
* MicroVM proxy long before a sync finished. Files are therefore transferred in
|
|
44
|
+
* batches as a single gzipped tar archive per batch, which is one request for a
|
|
45
|
+
* few hundred files instead of one request per file. Sandboxes without `tar`,
|
|
46
|
+
* `gzip`, or `base64` fall back to per-file reads.
|
|
47
|
+
*/
|
|
48
|
+
export const ARCHIVE_BATCH_SIZE = 300;
|
|
49
|
+
const TAR_BLOCK_SIZE = 512;
|
|
50
|
+
|
|
35
51
|
function normalizeRelativePath(inputPath: string): string {
|
|
36
52
|
const normalized = inputPath.split(path.posix.sep).join(path.sep);
|
|
37
53
|
const relative = path.normalize(normalized);
|
|
@@ -209,6 +225,109 @@ async function listRemoteWorkspaceEntries(
|
|
|
209
225
|
return { directories, files };
|
|
210
226
|
}
|
|
211
227
|
|
|
228
|
+
function readTarString(header: Buffer, offset: number, length: number): string {
|
|
229
|
+
const field = header.subarray(offset, offset + length);
|
|
230
|
+
const end = field.indexOf(0);
|
|
231
|
+
return field.subarray(0, end === -1 ? field.length : end).toString('utf8');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Extract the regular files from a ustar archive, keyed by member name.
|
|
236
|
+
* Supports the `prefix` field, GNU long-name (`L`) entries, and pax (`x`)
|
|
237
|
+
* `path` records, which is how the common tar implementations spell a member
|
|
238
|
+
* path longer than 100 characters.
|
|
239
|
+
*/
|
|
240
|
+
function parseTarFiles(archive: Buffer): Map<string, Buffer> {
|
|
241
|
+
const files = new Map<string, Buffer>();
|
|
242
|
+
let overrideName: string | undefined;
|
|
243
|
+
let offset = 0;
|
|
244
|
+
|
|
245
|
+
while (offset + TAR_BLOCK_SIZE <= archive.length) {
|
|
246
|
+
const header = archive.subarray(offset, offset + TAR_BLOCK_SIZE);
|
|
247
|
+
if (header.every(byte => byte === 0)) break;
|
|
248
|
+
|
|
249
|
+
const size = Number.parseInt(
|
|
250
|
+
readTarString(header, 124, 12).trim() || '0',
|
|
251
|
+
8,
|
|
252
|
+
);
|
|
253
|
+
const typeFlag = String.fromCharCode(header[156] as number);
|
|
254
|
+
const dataStart = offset + TAR_BLOCK_SIZE;
|
|
255
|
+
const data = archive.subarray(dataStart, dataStart + size);
|
|
256
|
+
offset = dataStart + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
|
|
257
|
+
|
|
258
|
+
if (typeFlag === 'L') {
|
|
259
|
+
overrideName = readTarString(data, 0, data.length);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (typeFlag === 'x' || typeFlag === 'X') {
|
|
263
|
+
const pathRecord = data
|
|
264
|
+
.toString('utf8')
|
|
265
|
+
.split('\n')
|
|
266
|
+
.map(record => /^\d+ path=(.*)$/.exec(record)?.[1])
|
|
267
|
+
.find(value => value != null);
|
|
268
|
+
if (pathRecord != null) overrideName = pathRecord;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (typeFlag === '0' || typeFlag === '\0') {
|
|
273
|
+
const name = readTarString(header, 0, 100);
|
|
274
|
+
const prefix = readTarString(header, 345, 155);
|
|
275
|
+
files.set(
|
|
276
|
+
overrideName ?? (prefix === '' ? name : `${prefix}/${name}`),
|
|
277
|
+
Buffer.from(data),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
overrideName = undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return files;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Transfer a batch of sandbox files with a single command. Returns the decoded
|
|
288
|
+
* contents keyed by the requested sandbox path, leaving out anything the
|
|
289
|
+
* sandbox could not archive (no `tar`/`gzip`/`base64`, or a file that vanished
|
|
290
|
+
* mid-sync) so the caller can fall back to a per-file read.
|
|
291
|
+
*/
|
|
292
|
+
async function readSandboxFilesAsArchive(
|
|
293
|
+
sandbox: Experimental_SandboxSession,
|
|
294
|
+
sandboxPaths: string[],
|
|
295
|
+
): Promise<Map<string, Buffer>> {
|
|
296
|
+
// Archive relative to `/` so member names are the requested absolute paths
|
|
297
|
+
// without their leading slash, wherever the files live: the traversal
|
|
298
|
+
// resolves symlinks, so these paths can point outside the workspace.
|
|
299
|
+
const members = sandboxPaths.map(sandboxPath =>
|
|
300
|
+
sandboxPath.replace(/^\/+/, ''),
|
|
301
|
+
);
|
|
302
|
+
const command = `tar -C / -czf - -- ${members
|
|
303
|
+
.map(shellQuote)
|
|
304
|
+
.join(' ')} 2>/dev/null | base64`;
|
|
305
|
+
|
|
306
|
+
let encoded: string;
|
|
307
|
+
try {
|
|
308
|
+
encoded = (await readCommandOutput(sandbox, command)).replace(/\s+/g, '');
|
|
309
|
+
} catch {
|
|
310
|
+
return new Map();
|
|
311
|
+
}
|
|
312
|
+
if (encoded === '') return new Map();
|
|
313
|
+
|
|
314
|
+
let entries: Map<string, Buffer>;
|
|
315
|
+
try {
|
|
316
|
+
entries = parseTarFiles(gunzipSync(Buffer.from(encoded, 'base64')));
|
|
317
|
+
} catch {
|
|
318
|
+
return new Map();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const contents = new Map<string, Buffer>();
|
|
322
|
+
for (const [index, member] of members.entries()) {
|
|
323
|
+
const content = entries.get(member);
|
|
324
|
+
if (content != null) {
|
|
325
|
+
contents.set(sandboxPaths[index] as string, content);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return contents;
|
|
329
|
+
}
|
|
330
|
+
|
|
212
331
|
async function pathKind(
|
|
213
332
|
target: string,
|
|
214
333
|
): Promise<'file' | 'directory' | undefined> {
|
|
@@ -330,27 +449,45 @@ export async function syncHostWorkspaceFromSandbox(args: {
|
|
|
330
449
|
await mkdir(path.join(hostWorkDir, relativePath), { recursive: true });
|
|
331
450
|
}
|
|
332
451
|
|
|
333
|
-
for (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
|
|
452
|
+
for (
|
|
453
|
+
let offset = 0;
|
|
454
|
+
offset < remoteEntries.files.length;
|
|
455
|
+
offset += ARCHIVE_BATCH_SIZE
|
|
456
|
+
) {
|
|
457
|
+
const batch = remoteEntries.files.slice(
|
|
458
|
+
offset,
|
|
459
|
+
offset + ARCHIVE_BATCH_SIZE,
|
|
460
|
+
);
|
|
461
|
+
const archived = await readSandboxFilesAsArchive(
|
|
462
|
+
sandbox,
|
|
463
|
+
batch.map(file => file.sandboxPath),
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
for (const { relativePath, sandboxPath } of batch) {
|
|
467
|
+
let content = archived.get(sandboxPath);
|
|
468
|
+
if (content == null) {
|
|
469
|
+
const bytes = await sandbox.readBinaryFile({ path: sandboxPath });
|
|
470
|
+
if (!bytes) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`Sandbox workspace file disappeared during mirror sync: ${sandboxPath}`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
content = Buffer.from(bytes);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const hostPath = path.join(hostWorkDir, relativePath);
|
|
479
|
+
let shouldWrite = true;
|
|
480
|
+
try {
|
|
481
|
+
const existing = await readFile(hostPath);
|
|
482
|
+
shouldWrite = !existing.equals(content);
|
|
483
|
+
} catch {
|
|
484
|
+
shouldWrite = true;
|
|
485
|
+
}
|
|
350
486
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
487
|
+
if (shouldWrite) {
|
|
488
|
+
await mkdir(path.dirname(hostPath), { recursive: true });
|
|
489
|
+
await writeFile(hostPath, content);
|
|
490
|
+
}
|
|
354
491
|
}
|
|
355
492
|
}
|
|
356
493
|
}
|