start-command 0.42.0 → 0.44.0
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/.changeset/README.md +1 -1
- package/CHANGELOG.md +26 -0
- package/README.md +2 -0
- package/package.json +6 -6
- package/src/lib/args-parser-queries.js +64 -3
- package/src/lib/docker-inspect-batch.js +3 -0
- package/src/lib/execution-attach.js +10 -1
- package/src/lib/execution-cleanup.js +8 -0
- package/src/lib/execution-control.js +14 -3
- package/src/lib/execution-pause.js +701 -0
- package/src/lib/execution-recovery.js +6 -0
- package/src/lib/execution-resume-all.js +14 -0
- package/src/lib/execution-resume.js +16 -1
- package/src/lib/execution-store.js +5 -1
- package/src/lib/formal-ai-client.js +8 -5
- package/src/lib/formal-ai-files.js +474 -61
- package/src/lib/formal-ai.js +170 -7
- package/src/lib/log-sanitizer.js +5 -0
- package/src/lib/query-commands.js +33 -1
- package/src/lib/session-probe.js +27 -1
- package/src/lib/status-formatter.js +13 -3
- package/src/lib/status-probe.js +19 -5
- package/src/lib/usage.js +8 -1
- package/test/args-parser-attach-resume.js +1 -1
- package/test/args-parser-control.js +3 -3
- package/test/args-parser.js +1 -1
- package/test/fixtures/formal-ai/receipts-258.json +27 -9
- package/test/fixtures/formal-ai/receipts-261.json +589 -0
- package/test/fixtures/formal-ai/replay.cjs +4 -1
- package/test/fixtures/pause-golden.cjs +142 -0
- package/test/regression-243.js +4 -1
- package/test/regression-258-files.js +27 -5
- package/test/regression-258.js +22 -9
- package/test/regression-261.js +856 -0
- package/test/regression-263.js +867 -0
- package/test/shared-golden.js +28 -3
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* byte for byte, so comments, whitespace and unrelated content survive.
|
|
10
10
|
*
|
|
11
11
|
* - read: the exact UTF-8 text of a regular file, bounded by
|
|
12
|
-
* START_FORMAL_AI_MAX_FILE_BYTES
|
|
12
|
+
* START_FORMAL_AI_MAX_FILE_BYTES, as the numbered frame Formal AI decodes
|
|
13
|
+
* back to that text (#261); it observes the file for later changes.
|
|
13
14
|
* - edit: replaces one literal occurrence of old_string (or every one with
|
|
14
15
|
* replace_all) in a file read before, atomically, then reads it back.
|
|
15
16
|
* - write: replaces an observed file, or creates one in an observed
|
|
@@ -19,6 +20,15 @@
|
|
|
19
20
|
* symlinked-out paths), edit and write need START_FORMAL_AI_EFFECTS=
|
|
20
21
|
* workspace-write and an earlier observation of the target, and a file that
|
|
21
22
|
* changed since start read it is not overwritten.
|
|
23
|
+
*
|
|
24
|
+
* Bounded typed source edits (issue #261): a change to an existing file is
|
|
25
|
+
* bound to the complete bytes the read tool returned for it in this task
|
|
26
|
+
* (listing it or `cat` is not enough), applied to exactly the matched regions
|
|
27
|
+
* of those bytes, guarded against a change of the target until the new file
|
|
28
|
+
* takes its place, and verified by reading it back: every matched region now
|
|
29
|
+
* holds new_string and every other byte is unchanged and in place. The
|
|
30
|
+
* mutation receipt (preimage, postimage and byte offsets of the matches) is
|
|
31
|
+
* kept in the evidence and returned to Formal AI.
|
|
22
32
|
*/
|
|
23
33
|
|
|
24
34
|
const crypto = require('crypto');
|
|
@@ -50,7 +60,7 @@ const FILE_TOOLS = Object.freeze({
|
|
|
50
60
|
function: {
|
|
51
61
|
name: 'edit',
|
|
52
62
|
description:
|
|
53
|
-
'Replace the literal text old_string with new_string in one file read
|
|
63
|
+
'Replace the literal text old_string with new_string in one file read completely with read in this task, unchanged since; old_string must occur exactly once unless replace_all is true. Nothing else in the file changes.',
|
|
54
64
|
parameters: {
|
|
55
65
|
type: 'object',
|
|
56
66
|
properties: {
|
|
@@ -68,7 +78,7 @@ const FILE_TOOLS = Object.freeze({
|
|
|
68
78
|
function: {
|
|
69
79
|
name: 'write',
|
|
70
80
|
description:
|
|
71
|
-
'Write the whole text of one file: a file read
|
|
81
|
+
'Write the whole text of one file: a file read completely with read in this task, unchanged since, or a new file in a directory listed before.',
|
|
72
82
|
parameters: {
|
|
73
83
|
type: 'object',
|
|
74
84
|
properties: { path: PATH, content: { type: 'string' } },
|
|
@@ -190,38 +200,84 @@ function evaluateFileCall(tool, args, context) {
|
|
|
190
200
|
const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
191
201
|
// ignoreBOM keeps a byte order mark in the text, so an edit writes it back.
|
|
192
202
|
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
203
|
+
// The final path component is opened without following a symbolic link: the
|
|
204
|
+
// policy resolved the target, so a link there now is a swapped path (#261).
|
|
205
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
193
206
|
|
|
194
207
|
class FileToolError extends Error {}
|
|
195
208
|
|
|
196
|
-
/**
|
|
197
|
-
function
|
|
198
|
-
let stat;
|
|
209
|
+
/** lstat of `file`, or null when it does not exist. */
|
|
210
|
+
function lstatOrNull(file) {
|
|
199
211
|
try {
|
|
200
|
-
|
|
212
|
+
return fs.lstatSync(file);
|
|
201
213
|
} catch (error) {
|
|
214
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
202
217
|
throw new FileToolError(
|
|
203
|
-
error.code
|
|
204
|
-
? 'no such file'
|
|
205
|
-
: `cannot read the file (${error.code || error.message})`
|
|
218
|
+
`cannot inspect the file (${error.code || error.message})`
|
|
206
219
|
);
|
|
207
220
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The exact bytes of a regular workspace file, read through one descriptor
|
|
225
|
+
* opened without following a symbolic link.
|
|
226
|
+
*/
|
|
227
|
+
function readBytes(file, maxFileBytes) {
|
|
228
|
+
const link = lstatOrNull(file);
|
|
229
|
+
if (link && link.isSymbolicLink()) {
|
|
215
230
|
throw new FileToolError(
|
|
216
|
-
|
|
231
|
+
'is a symbolic link that appeared after start resolved the path; nothing changed'
|
|
217
232
|
);
|
|
218
233
|
}
|
|
219
|
-
|
|
220
|
-
|
|
234
|
+
// Refused before opening, as the Rust port must on Windows (opening a
|
|
235
|
+
// directory as a file is denied there); the handle check covers a swap.
|
|
236
|
+
if (link && link.isDirectory()) {
|
|
237
|
+
throw new FileToolError('is a directory, not a file');
|
|
238
|
+
}
|
|
239
|
+
let handle;
|
|
240
|
+
try {
|
|
241
|
+
handle = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
242
|
+
} catch (error) {
|
|
221
243
|
throw new FileToolError(
|
|
222
|
-
|
|
244
|
+
error.code === 'ENOENT'
|
|
245
|
+
? 'no such file'
|
|
246
|
+
: error.code === 'ELOOP'
|
|
247
|
+
? 'is a symbolic link that appeared after start resolved the path; nothing changed'
|
|
248
|
+
: error.code === 'EISDIR'
|
|
249
|
+
? 'is a directory, not a file'
|
|
250
|
+
: `cannot read the file (${error.code || error.message})`
|
|
223
251
|
);
|
|
224
252
|
}
|
|
253
|
+
try {
|
|
254
|
+
const stat = fs.fstatSync(handle);
|
|
255
|
+
if (stat.isDirectory()) {
|
|
256
|
+
throw new FileToolError('is a directory, not a file');
|
|
257
|
+
}
|
|
258
|
+
if (!stat.isFile()) {
|
|
259
|
+
throw new FileToolError('is not a regular file');
|
|
260
|
+
}
|
|
261
|
+
if (stat.size > maxFileBytes) {
|
|
262
|
+
throw new FileToolError(
|
|
263
|
+
`has ${stat.size} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const bytes = fs.readFileSync(handle);
|
|
267
|
+
if (bytes.length > maxFileBytes) {
|
|
268
|
+
throw new FileToolError(
|
|
269
|
+
`has ${bytes.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return { bytes, mode: stat.mode };
|
|
273
|
+
} finally {
|
|
274
|
+
fs.closeSync(handle);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Text of a regular workspace file, refusing what read could not return exactly. */
|
|
279
|
+
function readText(file, maxFileBytes) {
|
|
280
|
+
const { bytes, mode } = readBytes(file, maxFileBytes);
|
|
225
281
|
let text;
|
|
226
282
|
try {
|
|
227
283
|
text = decoder.decode(bytes);
|
|
@@ -231,11 +287,25 @@ function readText(file, maxFileBytes) {
|
|
|
231
287
|
if (text === null || text.includes('\0')) {
|
|
232
288
|
throw new FileToolError('is not UTF-8 text');
|
|
233
289
|
}
|
|
234
|
-
return { text, bytes, mode
|
|
290
|
+
return { text, bytes, mode };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** True when `text` has no lone UTF-16 surrogate (it encodes to UTF-8 exactly). */
|
|
294
|
+
function wellFormed(text) {
|
|
295
|
+
return typeof text.isWellFormed === 'function'
|
|
296
|
+
? text.isWellFormed()
|
|
297
|
+
: !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(
|
|
298
|
+
text
|
|
299
|
+
);
|
|
235
300
|
}
|
|
236
301
|
|
|
237
|
-
/**
|
|
238
|
-
|
|
302
|
+
/**
|
|
303
|
+
* Replace `file` by `content` through a temporary sibling. Just before the
|
|
304
|
+
* temporary file takes the target's place, `guard()` checks that the target
|
|
305
|
+
* still holds the preimage start bound the change to; a new file is linked in
|
|
306
|
+
* place, so a file that appeared meanwhile is never overwritten.
|
|
307
|
+
*/
|
|
308
|
+
function writeAtomically(file, content, mode, guard, create) {
|
|
239
309
|
const temporary = path.join(
|
|
240
310
|
path.dirname(file),
|
|
241
311
|
`.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.start-tmp`
|
|
@@ -251,12 +321,27 @@ function writeAtomically(file, content, mode) {
|
|
|
251
321
|
} finally {
|
|
252
322
|
fs.closeSync(handle);
|
|
253
323
|
}
|
|
254
|
-
|
|
324
|
+
guard();
|
|
325
|
+
if (create) {
|
|
326
|
+
try {
|
|
327
|
+
fs.linkSync(temporary, file);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (error.code === 'EEXIST') {
|
|
330
|
+
throw new FileToolError(
|
|
331
|
+
'was created while start was writing it; nothing changed'
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
fs.renameSync(temporary, file);
|
|
338
|
+
}
|
|
255
339
|
} finally {
|
|
256
340
|
fs.rmSync(temporary, { force: true });
|
|
257
341
|
}
|
|
258
342
|
}
|
|
259
343
|
|
|
344
|
+
/** Indices of the non-overlapping occurrences of `needle` in `text`. */
|
|
260
345
|
function occurrences(text, needle) {
|
|
261
346
|
const found = [];
|
|
262
347
|
for (
|
|
@@ -269,19 +354,180 @@ function occurrences(text, needle) {
|
|
|
269
354
|
return found;
|
|
270
355
|
}
|
|
271
356
|
|
|
357
|
+
/** Byte-level splice of `preimage`: each match's old bytes become `newBytes`. */
|
|
358
|
+
function splice(preimage, matches, newBytes) {
|
|
359
|
+
const parts = [];
|
|
360
|
+
let cursor = 0;
|
|
361
|
+
for (const match of matches) {
|
|
362
|
+
parts.push(preimage.subarray(cursor, match.offset), newBytes);
|
|
363
|
+
cursor = match.offset + match.oldBytes;
|
|
364
|
+
}
|
|
365
|
+
parts.push(preimage.subarray(cursor));
|
|
366
|
+
return Buffer.concat(parts);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Check a readback against the mutation receipt, independently of how the
|
|
371
|
+
* new text was computed: every matched preimage region held exactly the old
|
|
372
|
+
* bytes, every other preimage byte is unchanged and in place, every replaced
|
|
373
|
+
* region holds exactly the new bytes, and nothing else was added.
|
|
374
|
+
* @param {Buffer} preimage - Bytes the change was bound to
|
|
375
|
+
* @param {Buffer} readback - Bytes read back after the change
|
|
376
|
+
* @param {object[]} matches - [{offset, oldBytes, newBytes}] in byte units
|
|
377
|
+
* @param {Buffer} oldBytes - UTF-8 bytes of old_string
|
|
378
|
+
* @param {Buffer} newBytes - UTF-8 bytes of new_string
|
|
379
|
+
* @returns {{verified: boolean, detail: string}}
|
|
380
|
+
*/
|
|
381
|
+
function verifyMutation(preimage, readback, matches, oldBytes, newBytes) {
|
|
382
|
+
const bad = (detail) => ({ verified: false, detail });
|
|
383
|
+
if (!matches.length) {
|
|
384
|
+
return bad('the mutation receipt has no matched region');
|
|
385
|
+
}
|
|
386
|
+
const delta = matches.length * (newBytes.length - oldBytes.length);
|
|
387
|
+
if (readback.length !== preimage.length + delta) {
|
|
388
|
+
return bad(
|
|
389
|
+
`readback has ${readback.length} bytes, expected ${preimage.length + delta}`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
let before = 0;
|
|
393
|
+
let after = 0;
|
|
394
|
+
for (const [index, match] of matches.entries()) {
|
|
395
|
+
if (
|
|
396
|
+
match.offset < before ||
|
|
397
|
+
match.oldBytes !== oldBytes.length ||
|
|
398
|
+
match.newBytes !== newBytes.length ||
|
|
399
|
+
match.offset + match.oldBytes > preimage.length
|
|
400
|
+
) {
|
|
401
|
+
return bad(`match ${index + 1} of the mutation receipt is malformed`);
|
|
402
|
+
}
|
|
403
|
+
const keep = match.offset - before;
|
|
404
|
+
if (
|
|
405
|
+
!preimage
|
|
406
|
+
.subarray(before, match.offset)
|
|
407
|
+
.equals(readback.subarray(after, after + keep))
|
|
408
|
+
) {
|
|
409
|
+
return bad(`bytes before match ${index + 1} changed`);
|
|
410
|
+
}
|
|
411
|
+
after += keep;
|
|
412
|
+
if (
|
|
413
|
+
!preimage
|
|
414
|
+
.subarray(match.offset, match.offset + match.oldBytes)
|
|
415
|
+
.equals(oldBytes)
|
|
416
|
+
) {
|
|
417
|
+
return bad(`match ${index + 1} did not hold old_string in the preimage`);
|
|
418
|
+
}
|
|
419
|
+
if (!readback.subarray(after, after + newBytes.length).equals(newBytes)) {
|
|
420
|
+
return bad(
|
|
421
|
+
`match ${index + 1} does not hold new_string after the change`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
before = match.offset + match.oldBytes;
|
|
425
|
+
after += newBytes.length;
|
|
426
|
+
}
|
|
427
|
+
if (!preimage.subarray(before).equals(readback.subarray(after))) {
|
|
428
|
+
return bad('bytes after the last match changed');
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
verified: true,
|
|
432
|
+
detail: `${matches.length} replacement(s); every other byte unchanged`,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const digest = (bytes) => ({ sha256: sha256(bytes), bytes: bytes.length });
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The complete text of a read as the numbered frame Formal AI binds as a
|
|
440
|
+
* whole-file Read (#261): one `NNNNN| line` per `\n`-separated line and a
|
|
441
|
+
* trailer with the line count. Formal AI decodes it back to exactly `text`
|
|
442
|
+
* (lines joined with `\n`, so a missing or present final newline, CR bytes
|
|
443
|
+
* and Unicode survive); its OpenAI-compatible request parser drops any other
|
|
444
|
+
* metadata on a tool message, so the frame is the only binding it accepts.
|
|
445
|
+
* @param {string} text - Complete file text
|
|
446
|
+
* @returns {string} Framed read result
|
|
447
|
+
*/
|
|
448
|
+
function readFrame(text) {
|
|
449
|
+
const lines = text.split('\n');
|
|
450
|
+
const width = Math.max(5, String(lines.length).length);
|
|
451
|
+
const numbered = lines.map(
|
|
452
|
+
(line, index) => `${String(index + 1).padStart(width, '0')}| ${line}`
|
|
453
|
+
);
|
|
454
|
+
return `<file>\n${numbered.join('\n')}\n\n(End of file - total ${lines.length} lines)\n</file>`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The exact text a readFrame() result frames, or null when it is not one.
|
|
459
|
+
* @param {string} frame - Read result
|
|
460
|
+
* @returns {string|null} Decoded text
|
|
461
|
+
*/
|
|
462
|
+
function readFrameText(frame) {
|
|
463
|
+
const open = '<file>\n';
|
|
464
|
+
const marker = '\n\n(End of file - total ';
|
|
465
|
+
if (!frame.startsWith(open) || !frame.endsWith('</file>')) {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
const at = frame.lastIndexOf(marker);
|
|
469
|
+
if (at < 0) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
const lines = frame.slice(open.length, at).split('\n');
|
|
473
|
+
const count = Number(frame.slice(at + marker.length).split(/\s+/u)[0]);
|
|
474
|
+
if (count !== lines.length) {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
const out = [];
|
|
478
|
+
for (const [index, line] of lines.entries()) {
|
|
479
|
+
const split = line.indexOf('| ');
|
|
480
|
+
if (split < 0 || Number(line.slice(0, split)) !== index + 1) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
out.push(line.slice(split + 2));
|
|
484
|
+
}
|
|
485
|
+
return out.join('\n');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Whether a tool result of the conversation already contained `text`, as
|
|
490
|
+
* sent or as the file text of a read frame (whose lines carry numbers): an
|
|
491
|
+
* answer that only echoes file data back is not Formal AI's own (#261).
|
|
492
|
+
* @param {string} text - Trimmed answer
|
|
493
|
+
* @param {object[]} messages - Conversation so far
|
|
494
|
+
* @returns {boolean}
|
|
495
|
+
*/
|
|
496
|
+
function echoedByTool(text, messages) {
|
|
497
|
+
return messages.some((m) => {
|
|
498
|
+
if (!m || m.role !== 'tool') {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
const content = String(m.content ?? '');
|
|
502
|
+
return (
|
|
503
|
+
content.includes(text) || (readFrameText(content) ?? '').includes(text)
|
|
504
|
+
);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
272
508
|
/**
|
|
273
509
|
* Run an approved file tool call.
|
|
510
|
+
*
|
|
511
|
+
* A change is bound to the complete bytes of the target that this task last
|
|
512
|
+
* read with the read tool (or wrote and verified): the edit is applied to
|
|
513
|
+
* those bytes only, the target is checked to still hold them just before the
|
|
514
|
+
* new file takes its place, and the readback is verified byte for byte
|
|
515
|
+
* against the mutation receipt (edit) or the whole intended text (write).
|
|
274
516
|
* @param {string} tool - read, edit or write
|
|
275
517
|
* @param {object} args - normalizeFileArgs() result
|
|
276
518
|
* @param {object} decision - evaluateFileCall() result
|
|
277
|
-
* @param {object} context - Loop context; `contents` maps paths to the sha256
|
|
519
|
+
* @param {object} context - Loop context; `contents` maps paths to the sha256
|
|
520
|
+
* of the complete bytes start last read or verified; optional
|
|
521
|
+
* `beforeReplace(file)` and `afterReplace(file)` hooks run just before the
|
|
522
|
+
* guard and just before the readback (tests)
|
|
278
523
|
* @param {object} bounds - {maxFileBytes}
|
|
279
|
-
* @returns {object} Receipt {exitCode, result, error?, bytes?, sha256?,
|
|
524
|
+
* @returns {object} Receipt {exitCode, result, error?, bytes?, sha256?,
|
|
525
|
+
* previousSha256?, replacements?, mutation?, verification?}
|
|
280
526
|
*/
|
|
281
527
|
function runFileCall(tool, args, decision, context, bounds) {
|
|
282
528
|
const file = path.join(fs.realpathSync(context.cwd), decision.target);
|
|
283
529
|
const { target } = decision;
|
|
284
|
-
const fail = (message) => ({
|
|
530
|
+
const fail = (message, extra = {}) => ({
|
|
285
531
|
exitCode: 1,
|
|
286
532
|
error: `${target}: ${message}`,
|
|
287
533
|
result: JSON.stringify({
|
|
@@ -289,27 +535,49 @@ function runFileCall(tool, args, decision, context, bounds) {
|
|
|
289
535
|
is_error: true,
|
|
290
536
|
error: `${target}: ${message}`,
|
|
291
537
|
}),
|
|
538
|
+
...extra,
|
|
292
539
|
});
|
|
293
|
-
|
|
294
|
-
|
|
540
|
+
if (tool === 'read') {
|
|
541
|
+
try {
|
|
295
542
|
const { text, bytes } = readText(file, bounds.maxFileBytes);
|
|
296
543
|
context.observed.add(target);
|
|
297
544
|
context.contents.set(target, sha256(bytes));
|
|
298
545
|
return {
|
|
299
546
|
exitCode: 0,
|
|
300
|
-
result: text,
|
|
547
|
+
result: readFrame(text),
|
|
301
548
|
text,
|
|
302
549
|
bytes: bytes.length,
|
|
303
550
|
sha256: sha256(bytes),
|
|
304
551
|
};
|
|
552
|
+
} catch (error) {
|
|
553
|
+
return fail(
|
|
554
|
+
error instanceof FileToolError
|
|
555
|
+
? error.message
|
|
556
|
+
: `failed (${error.code || error.message})`
|
|
557
|
+
);
|
|
305
558
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
559
|
+
}
|
|
560
|
+
let current = null;
|
|
561
|
+
let mutation;
|
|
562
|
+
let expected;
|
|
563
|
+
try {
|
|
564
|
+
const existing = lstatOrNull(file);
|
|
565
|
+
if (existing && existing.isSymbolicLink()) {
|
|
566
|
+
return fail(
|
|
567
|
+
'is a symbolic link start cannot resolve inside the working directory; nothing changed'
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
if (tool === 'edit' || existing) {
|
|
309
571
|
const seen = context.contents.get(target);
|
|
310
|
-
if (seen
|
|
572
|
+
if (seen === undefined) {
|
|
311
573
|
return fail(
|
|
312
|
-
'
|
|
574
|
+
'was not read completely with the read tool in this task; read it before changing it; nothing changed'
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
current = readText(file, bounds.maxFileBytes);
|
|
578
|
+
if (seen !== sha256(current.bytes)) {
|
|
579
|
+
return fail(
|
|
580
|
+
'changed since start read it; read it again before changing it; nothing changed'
|
|
313
581
|
);
|
|
314
582
|
}
|
|
315
583
|
} else if (
|
|
@@ -317,14 +585,18 @@ function runFileCall(tool, args, decision, context, bounds) {
|
|
|
317
585
|
) {
|
|
318
586
|
return fail('its directory does not exist');
|
|
319
587
|
}
|
|
320
|
-
let
|
|
321
|
-
let replacements = null;
|
|
588
|
+
let matches = null;
|
|
322
589
|
if (tool === 'edit') {
|
|
323
590
|
if (args.oldString === '') {
|
|
324
|
-
return fail('old_string is empty');
|
|
591
|
+
return fail('old_string is empty; nothing changed');
|
|
325
592
|
}
|
|
326
593
|
if (args.oldString === args.newString) {
|
|
327
|
-
return fail('old_string and new_string are the same');
|
|
594
|
+
return fail('old_string and new_string are the same; nothing changed');
|
|
595
|
+
}
|
|
596
|
+
if (!wellFormed(args.oldString) || !wellFormed(args.newString)) {
|
|
597
|
+
return fail(
|
|
598
|
+
'old_string or new_string is not valid Unicode text; nothing changed'
|
|
599
|
+
);
|
|
328
600
|
}
|
|
329
601
|
const found = occurrences(current.text, args.oldString);
|
|
330
602
|
if (!found.length) {
|
|
@@ -335,50 +607,191 @@ function runFileCall(tool, args, decision, context, bounds) {
|
|
|
335
607
|
`old_string occurs ${found.length} times; give more surrounding text or set replace_all; nothing changed`
|
|
336
608
|
);
|
|
337
609
|
}
|
|
338
|
-
|
|
339
|
-
|
|
610
|
+
const oldBytes = Buffer.from(args.oldString, 'utf8');
|
|
611
|
+
const newBytes = Buffer.from(args.newString, 'utf8');
|
|
612
|
+
matches = (args.replaceAll ? found : found.slice(0, 1)).map((at) => ({
|
|
613
|
+
offset: Buffer.byteLength(current.text.slice(0, at), 'utf8'),
|
|
614
|
+
oldBytes: oldBytes.length,
|
|
615
|
+
newBytes: newBytes.length,
|
|
616
|
+
}));
|
|
617
|
+
expected = splice(current.bytes, matches, newBytes);
|
|
618
|
+
// The byte splice and the text replacement must agree exactly.
|
|
619
|
+
const text = args.replaceAll
|
|
340
620
|
? current.text.split(args.oldString).join(args.newString)
|
|
341
621
|
: `${current.text.slice(0, found[0])}${args.newString}${current.text.slice(found[0] + args.oldString.length)}`;
|
|
622
|
+
if (!expected.equals(Buffer.from(text, 'utf8'))) {
|
|
623
|
+
return fail(
|
|
624
|
+
'the replacement does not map to whole UTF-8 characters; nothing changed'
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
mutation = { oldBytes, newBytes };
|
|
342
628
|
} else {
|
|
343
|
-
|
|
629
|
+
if (!wellFormed(args.content)) {
|
|
630
|
+
return fail('content is not valid Unicode text; nothing changed');
|
|
631
|
+
}
|
|
632
|
+
expected = Buffer.from(args.content, 'utf8');
|
|
344
633
|
}
|
|
345
|
-
const expected = Buffer.from(updated, 'utf8');
|
|
346
634
|
if (expected.length > bounds.maxFileBytes) {
|
|
347
635
|
return fail(
|
|
348
|
-
`the new text has ${expected.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${bounds.maxFileBytes}`
|
|
636
|
+
`the new text has ${expected.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${bounds.maxFileBytes}; nothing changed`
|
|
349
637
|
);
|
|
350
638
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
639
|
+
mutation = {
|
|
640
|
+
tool,
|
|
641
|
+
target,
|
|
642
|
+
preimage: current ? digest(current.bytes) : null,
|
|
643
|
+
postimage: digest(expected),
|
|
644
|
+
...(matches ? { matches } : {}),
|
|
645
|
+
...(mutation ? { _old: mutation.oldBytes, _new: mutation.newBytes } : {}),
|
|
646
|
+
};
|
|
647
|
+
const guard = () => {
|
|
648
|
+
if (context.beforeReplace) {
|
|
649
|
+
context.beforeReplace(file);
|
|
650
|
+
}
|
|
651
|
+
const now = lstatOrNull(file);
|
|
652
|
+
const unchanged = current
|
|
653
|
+
? now &&
|
|
654
|
+
!now.isSymbolicLink() &&
|
|
655
|
+
readBytes(file, bounds.maxFileBytes).bytes.equals(current.bytes)
|
|
656
|
+
: now === null;
|
|
657
|
+
if (!unchanged) {
|
|
658
|
+
throw new FileToolError(
|
|
659
|
+
current
|
|
660
|
+
? 'changed while start was changing it; read it again; nothing changed'
|
|
661
|
+
: 'was created while start was writing it; nothing changed'
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
writeAtomically(
|
|
666
|
+
file,
|
|
667
|
+
expected,
|
|
668
|
+
current ? current.mode : undefined,
|
|
669
|
+
guard,
|
|
670
|
+
!current
|
|
671
|
+
);
|
|
672
|
+
} catch (error) {
|
|
673
|
+
const message =
|
|
674
|
+
error instanceof FileToolError
|
|
675
|
+
? error.message
|
|
676
|
+
: `failed (${error.code || error.message})`;
|
|
677
|
+
if (!mutation) {
|
|
678
|
+
return fail(message);
|
|
679
|
+
}
|
|
680
|
+
// The change did not complete: say truthfully whether the target still
|
|
681
|
+
// holds its preimage, and forget a preimage that no longer holds.
|
|
682
|
+
const intact = stillHolds(file, current, bounds);
|
|
683
|
+
if (!intact) {
|
|
684
|
+
context.contents.delete(target);
|
|
685
|
+
}
|
|
686
|
+
return fail(
|
|
687
|
+
intact || /nothing changed$/.test(message)
|
|
688
|
+
? `${message.replace(/; nothing changed$/, '')}; nothing changed`
|
|
689
|
+
: `${message}; the file may have changed; read it again`,
|
|
690
|
+
{ mutation: publicMutation(mutation) }
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
context.observed.add(target);
|
|
694
|
+
const receipt = publicMutation(mutation);
|
|
695
|
+
let readback;
|
|
696
|
+
try {
|
|
697
|
+
if (context.afterReplace) {
|
|
698
|
+
context.afterReplace(file);
|
|
699
|
+
}
|
|
700
|
+
readback = readBytes(file, bounds.maxFileBytes).bytes;
|
|
701
|
+
} catch (error) {
|
|
702
|
+
context.contents.delete(target);
|
|
356
703
|
return {
|
|
357
704
|
exitCode: 0,
|
|
358
|
-
result:
|
|
705
|
+
result: changeResult(receipt, false),
|
|
359
706
|
bytes: expected.length,
|
|
360
|
-
sha256: sha256(readback),
|
|
361
707
|
previousSha256: current ? sha256(current.bytes) : null,
|
|
362
|
-
replacements,
|
|
708
|
+
replacements: receipt.matches ? receipt.matches.length : null,
|
|
709
|
+
mutation: receipt,
|
|
363
710
|
verification: {
|
|
364
|
-
verified,
|
|
365
|
-
detail:
|
|
366
|
-
? `readback confirmed ${tool} ${target} (${expected.length} bytes)`
|
|
367
|
-
: `readback of ${target} differs from the written text`,
|
|
711
|
+
verified: false,
|
|
712
|
+
detail: `readback of ${target} failed (${error.message})`,
|
|
368
713
|
},
|
|
369
714
|
};
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
|
|
715
|
+
}
|
|
716
|
+
const check =
|
|
717
|
+
tool === 'edit'
|
|
718
|
+
? verifyMutation(
|
|
719
|
+
current.bytes,
|
|
720
|
+
readback,
|
|
721
|
+
receipt.matches,
|
|
722
|
+
mutation._old,
|
|
723
|
+
mutation._new
|
|
724
|
+
)
|
|
725
|
+
: { verified: true, detail: 'whole text' };
|
|
726
|
+
const verified = check.verified && readback.equals(expected);
|
|
727
|
+
receipt.readback = digest(readback);
|
|
728
|
+
if (verified) {
|
|
729
|
+
context.contents.set(target, sha256(readback));
|
|
730
|
+
} else {
|
|
731
|
+
context.contents.delete(target);
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
exitCode: 0,
|
|
735
|
+
result: changeResult(receipt, verified),
|
|
736
|
+
bytes: expected.length,
|
|
737
|
+
sha256: sha256(readback),
|
|
738
|
+
previousSha256: current ? sha256(current.bytes) : null,
|
|
739
|
+
replacements: receipt.matches ? receipt.matches.length : null,
|
|
740
|
+
mutation: receipt,
|
|
741
|
+
verification: {
|
|
742
|
+
verified,
|
|
743
|
+
detail: verified
|
|
744
|
+
? `readback confirmed ${tool} ${target} (${expected.length} bytes; ${check.detail})`
|
|
745
|
+
: `readback of ${target} differs from the intended text${check.verified ? '' : ` (${check.detail})`}`,
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Whether `file` still holds the bound preimage (or is still absent). */
|
|
751
|
+
function stillHolds(file, current, bounds) {
|
|
752
|
+
try {
|
|
753
|
+
const now = lstatOrNull(file);
|
|
754
|
+
if (!current) {
|
|
755
|
+
return now === null;
|
|
373
756
|
}
|
|
374
|
-
return
|
|
757
|
+
return (
|
|
758
|
+
now !== null &&
|
|
759
|
+
!now.isSymbolicLink() &&
|
|
760
|
+
readBytes(file, bounds.maxFileBytes).bytes.equals(current.bytes)
|
|
761
|
+
);
|
|
762
|
+
} catch {
|
|
763
|
+
return false;
|
|
375
764
|
}
|
|
376
765
|
}
|
|
377
766
|
|
|
767
|
+
/** The mutation receipt without its internal byte buffers. */
|
|
768
|
+
function publicMutation(mutation) {
|
|
769
|
+
return Object.fromEntries(
|
|
770
|
+
Object.entries(mutation).filter(([key]) => key !== '_old' && key !== '_new')
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** The tool result Formal AI receives for a change: its receipt, as JSON. */
|
|
775
|
+
function changeResult(receipt, verified) {
|
|
776
|
+
return JSON.stringify({
|
|
777
|
+
ok: verified,
|
|
778
|
+
path: receipt.target,
|
|
779
|
+
tool: receipt.tool,
|
|
780
|
+
preimage: receipt.preimage,
|
|
781
|
+
postimage: receipt.postimage,
|
|
782
|
+
...(receipt.matches ? { replacements: receipt.matches.length } : {}),
|
|
783
|
+
verified,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
378
787
|
module.exports = {
|
|
379
788
|
FILE_TOOLS,
|
|
380
789
|
describeFileCall,
|
|
790
|
+
echoedByTool,
|
|
381
791
|
evaluateFileCall,
|
|
382
792
|
normalizeFileArgs,
|
|
793
|
+
readFrame,
|
|
794
|
+
readFrameText,
|
|
383
795
|
runFileCall,
|
|
796
|
+
verifyMutation,
|
|
384
797
|
};
|