start-command 0.41.0 → 0.42.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/CHANGELOG.md +10 -0
- package/package.json +2 -2
- package/src/lib/formal-ai-client.js +246 -22
- package/src/lib/formal-ai-files.js +384 -0
- package/src/lib/formal-ai-policy.js +10 -3
- package/src/lib/formal-ai.js +164 -11
- package/src/lib/usage.js +3 -1
- package/test/fixtures/formal-ai/receipts-258.json +431 -0
- package/test/fixtures/formal-ai/replay.cjs +57 -7
- package/test/regression-255-cli.js +3 -1
- package/test/regression-255-e2e.js +80 -3
- package/test/regression-255-loop.js +6 -1
- package/test/regression-255-policy.js +1 -1
- package/test/regression-257.js +265 -0
- package/test/regression-258-files.js +430 -0
- package/test/regression-258.js +443 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured `read`, `edit` and `write` tools of the experimental
|
|
3
|
+
* `--use-formal-ai` mode (issue #258).
|
|
4
|
+
*
|
|
5
|
+
* The `bash` tool runs only programs without shell syntax, and its write
|
|
6
|
+
* programs (cp, mkdir, mv, rm, rmdir, touch) cannot change the text of a
|
|
7
|
+
* file. These three tools are the guarded replacement primitive instead of an
|
|
8
|
+
* interpreter: start itself reads and writes the one named workspace file,
|
|
9
|
+
* byte for byte, so comments, whitespace and unrelated content survive.
|
|
10
|
+
*
|
|
11
|
+
* - read: the exact UTF-8 text of a regular file, bounded by
|
|
12
|
+
* START_FORMAL_AI_MAX_FILE_BYTES; it observes the file for later changes.
|
|
13
|
+
* - edit: replaces one literal occurrence of old_string (or every one with
|
|
14
|
+
* replace_all) in a file read before, atomically, then reads it back.
|
|
15
|
+
* - write: replaces an observed file, or creates one in an observed
|
|
16
|
+
* directory, atomically, then reads it back.
|
|
17
|
+
*
|
|
18
|
+
* Paths follow the bash tool's workspace policy (no absolute, escaping or
|
|
19
|
+
* symlinked-out paths), edit and write need START_FORMAL_AI_EFFECTS=
|
|
20
|
+
* workspace-write and an earlier observation of the target, and a file that
|
|
21
|
+
* changed since start read it is not overwritten.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const policy = require('./formal-ai-policy');
|
|
28
|
+
|
|
29
|
+
const { TextDecoder } = globalThis;
|
|
30
|
+
|
|
31
|
+
const PATH = {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'File path inside the working directory',
|
|
34
|
+
};
|
|
35
|
+
const FILE_TOOLS = Object.freeze({
|
|
36
|
+
read: Object.freeze({
|
|
37
|
+
type: 'function',
|
|
38
|
+
function: {
|
|
39
|
+
name: 'read',
|
|
40
|
+
description: 'Read the exact text of one file in the working directory.',
|
|
41
|
+
parameters: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: { path: PATH },
|
|
44
|
+
required: ['path'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
edit: Object.freeze({
|
|
49
|
+
type: 'function',
|
|
50
|
+
function: {
|
|
51
|
+
name: 'edit',
|
|
52
|
+
description:
|
|
53
|
+
'Replace the literal text old_string with new_string in one file read before; old_string must occur exactly once unless replace_all is true.',
|
|
54
|
+
parameters: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
path: PATH,
|
|
58
|
+
old_string: { type: 'string' },
|
|
59
|
+
new_string: { type: 'string' },
|
|
60
|
+
replace_all: { type: 'boolean' },
|
|
61
|
+
},
|
|
62
|
+
required: ['path', 'old_string', 'new_string'],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
write: Object.freeze({
|
|
67
|
+
type: 'function',
|
|
68
|
+
function: {
|
|
69
|
+
name: 'write',
|
|
70
|
+
description:
|
|
71
|
+
'Write the whole text of one file: a file read before, or a new file in a directory listed before.',
|
|
72
|
+
parameters: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: { path: PATH, content: { type: 'string' } },
|
|
75
|
+
required: ['path', 'content'],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** Argument names Formal AI and other agents use for the same field. */
|
|
82
|
+
const ALIASES = Object.freeze({
|
|
83
|
+
path: ['path', 'filePath', 'file_path'],
|
|
84
|
+
oldString: ['old_string', 'oldString', 'old_str', 'old'],
|
|
85
|
+
newString: ['new_string', 'newString', 'new_str', 'new'],
|
|
86
|
+
replaceAll: ['replace_all', 'replaceAll'],
|
|
87
|
+
content: ['content'],
|
|
88
|
+
});
|
|
89
|
+
const FIELDS = Object.freeze({
|
|
90
|
+
read: ['path'],
|
|
91
|
+
edit: ['path', 'oldString', 'newString', 'replaceAll'],
|
|
92
|
+
write: ['path', 'content'],
|
|
93
|
+
});
|
|
94
|
+
const REQUIRED = Object.freeze({
|
|
95
|
+
read: ['path'],
|
|
96
|
+
edit: ['path', 'oldString', 'newString'],
|
|
97
|
+
write: ['path', 'content'],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Normalize the JSON arguments of a file tool call.
|
|
102
|
+
* @param {string} tool - read, edit or write
|
|
103
|
+
* @param {object} args - Parsed arguments
|
|
104
|
+
* @returns {{args?: object, problem?: string}}
|
|
105
|
+
*/
|
|
106
|
+
function normalizeFileArgs(tool, args) {
|
|
107
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
108
|
+
return { problem: `${tool} arguments are not a JSON object` };
|
|
109
|
+
}
|
|
110
|
+
const result = {};
|
|
111
|
+
for (const field of FIELDS[tool]) {
|
|
112
|
+
const type = field === 'replaceAll' ? 'boolean' : 'string';
|
|
113
|
+
const values = ALIASES[field]
|
|
114
|
+
.filter((key) => Object.prototype.hasOwnProperty.call(args, key))
|
|
115
|
+
.map((key) => args[key]);
|
|
116
|
+
if (values.some((value) => typeof value !== type)) {
|
|
117
|
+
return {
|
|
118
|
+
problem: `${tool} argument ${ALIASES[field][0]} must be a ${type}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (values.some((value) => value !== values[0])) {
|
|
122
|
+
return {
|
|
123
|
+
problem: `${tool} received conflicting ${ALIASES[field][0]} values`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (values.length) {
|
|
127
|
+
result[field] = values[0];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const missing = REQUIRED[tool].filter((field) => !(field in result));
|
|
131
|
+
if (missing.length) {
|
|
132
|
+
return {
|
|
133
|
+
problem: `${tool} needs ${missing.map((f) => ALIASES[f][0]).join(', ')}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (!result.path.trim() || result.path.includes('\0')) {
|
|
137
|
+
return { problem: `${tool} needs a non-empty path` };
|
|
138
|
+
}
|
|
139
|
+
return { args: { replaceAll: false, ...result } };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One-line rendering of a file call for notes and evidence. */
|
|
143
|
+
function describeFileCall(tool, args) {
|
|
144
|
+
return `${tool} ${args.path}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Decide whether a file tool call may run (the effect policy of #255).
|
|
149
|
+
* @param {string} tool - read, edit or write
|
|
150
|
+
* @param {object} args - normalizeFileArgs() result
|
|
151
|
+
* @param {object} context - {cwd, effects, observed, observedDirs}
|
|
152
|
+
* @returns {{allowed: boolean, reason?: string, kind?: string, target?: string}}
|
|
153
|
+
*/
|
|
154
|
+
function evaluateFileCall(tool, args, context) {
|
|
155
|
+
const refuse = (reason) => ({ allowed: false, reason });
|
|
156
|
+
const kind = tool === 'read' ? 'read' : 'write';
|
|
157
|
+
if (kind === 'write' && context.effects !== 'workspace-write') {
|
|
158
|
+
return refuse(
|
|
159
|
+
`"${tool}" changes files; the effect mode is read-only (set START_FORMAL_AI_EFFECTS=workspace-write to allow workspace changes)`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (args.path.startsWith('~')) {
|
|
163
|
+
return refuse(`"${args.path}" is outside the working directory`);
|
|
164
|
+
}
|
|
165
|
+
const target = policy.workspacePath(context.cwd, args.path);
|
|
166
|
+
if (target === null) {
|
|
167
|
+
return refuse(`"${args.path}" is outside the working directory`);
|
|
168
|
+
}
|
|
169
|
+
if (target === '.') {
|
|
170
|
+
return refuse(`"${args.path}" is the working directory, not a file`);
|
|
171
|
+
}
|
|
172
|
+
if (kind === 'write') {
|
|
173
|
+
const exists = fs.existsSync(
|
|
174
|
+
path.join(fs.realpathSync(context.cwd), target)
|
|
175
|
+
);
|
|
176
|
+
const observed =
|
|
177
|
+
context.observed.has(target) ||
|
|
178
|
+
(tool === 'write' &&
|
|
179
|
+
!exists &&
|
|
180
|
+
context.observedDirs.has(path.dirname(target)));
|
|
181
|
+
if (!observed) {
|
|
182
|
+
return refuse(
|
|
183
|
+
`target "${target}" was not ${tool === 'edit' ? 'read' : 'observed'} before the change`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { allowed: true, kind, target };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
191
|
+
// ignoreBOM keeps a byte order mark in the text, so an edit writes it back.
|
|
192
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
193
|
+
|
|
194
|
+
class FileToolError extends Error {}
|
|
195
|
+
|
|
196
|
+
/** Text of a regular workspace file, refusing what read could not return exactly. */
|
|
197
|
+
function readText(file, maxFileBytes) {
|
|
198
|
+
let stat;
|
|
199
|
+
try {
|
|
200
|
+
stat = fs.statSync(file);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
throw new FileToolError(
|
|
203
|
+
error.code === 'ENOENT'
|
|
204
|
+
? 'no such file'
|
|
205
|
+
: `cannot read the file (${error.code || error.message})`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (stat.isDirectory()) {
|
|
209
|
+
throw new FileToolError('is a directory, not a file');
|
|
210
|
+
}
|
|
211
|
+
if (!stat.isFile()) {
|
|
212
|
+
throw new FileToolError('is not a regular file');
|
|
213
|
+
}
|
|
214
|
+
if (stat.size > maxFileBytes) {
|
|
215
|
+
throw new FileToolError(
|
|
216
|
+
`has ${stat.size} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const bytes = fs.readFileSync(file);
|
|
220
|
+
if (bytes.length > maxFileBytes) {
|
|
221
|
+
throw new FileToolError(
|
|
222
|
+
`has ${bytes.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
let text;
|
|
226
|
+
try {
|
|
227
|
+
text = decoder.decode(bytes);
|
|
228
|
+
} catch {
|
|
229
|
+
text = null;
|
|
230
|
+
}
|
|
231
|
+
if (text === null || text.includes('\0')) {
|
|
232
|
+
throw new FileToolError('is not UTF-8 text');
|
|
233
|
+
}
|
|
234
|
+
return { text, bytes, mode: stat.mode };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Replace `file` by `content` through a temporary sibling and a rename. */
|
|
238
|
+
function writeAtomically(file, content, mode) {
|
|
239
|
+
const temporary = path.join(
|
|
240
|
+
path.dirname(file),
|
|
241
|
+
`.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.start-tmp`
|
|
242
|
+
);
|
|
243
|
+
try {
|
|
244
|
+
fs.writeFileSync(temporary, content, { flag: 'wx', mode: mode ?? 0o666 });
|
|
245
|
+
if (mode !== undefined) {
|
|
246
|
+
fs.chmodSync(temporary, mode & 0o7777);
|
|
247
|
+
}
|
|
248
|
+
const handle = fs.openSync(temporary, 'r+');
|
|
249
|
+
try {
|
|
250
|
+
fs.fsyncSync(handle);
|
|
251
|
+
} finally {
|
|
252
|
+
fs.closeSync(handle);
|
|
253
|
+
}
|
|
254
|
+
fs.renameSync(temporary, file);
|
|
255
|
+
} finally {
|
|
256
|
+
fs.rmSync(temporary, { force: true });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function occurrences(text, needle) {
|
|
261
|
+
const found = [];
|
|
262
|
+
for (
|
|
263
|
+
let at = text.indexOf(needle);
|
|
264
|
+
at >= 0;
|
|
265
|
+
at = text.indexOf(needle, at + needle.length)
|
|
266
|
+
) {
|
|
267
|
+
found.push(at);
|
|
268
|
+
}
|
|
269
|
+
return found;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Run an approved file tool call.
|
|
274
|
+
* @param {string} tool - read, edit or write
|
|
275
|
+
* @param {object} args - normalizeFileArgs() result
|
|
276
|
+
* @param {object} decision - evaluateFileCall() result
|
|
277
|
+
* @param {object} context - Loop context; `contents` maps paths to the sha256 start last saw
|
|
278
|
+
* @param {object} bounds - {maxFileBytes}
|
|
279
|
+
* @returns {object} Receipt {exitCode, result, error?, bytes?, sha256?, replacements?, verification?}
|
|
280
|
+
*/
|
|
281
|
+
function runFileCall(tool, args, decision, context, bounds) {
|
|
282
|
+
const file = path.join(fs.realpathSync(context.cwd), decision.target);
|
|
283
|
+
const { target } = decision;
|
|
284
|
+
const fail = (message) => ({
|
|
285
|
+
exitCode: 1,
|
|
286
|
+
error: `${target}: ${message}`,
|
|
287
|
+
result: JSON.stringify({
|
|
288
|
+
ok: false,
|
|
289
|
+
is_error: true,
|
|
290
|
+
error: `${target}: ${message}`,
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
try {
|
|
294
|
+
if (tool === 'read') {
|
|
295
|
+
const { text, bytes } = readText(file, bounds.maxFileBytes);
|
|
296
|
+
context.observed.add(target);
|
|
297
|
+
context.contents.set(target, sha256(bytes));
|
|
298
|
+
return {
|
|
299
|
+
exitCode: 0,
|
|
300
|
+
result: text,
|
|
301
|
+
text,
|
|
302
|
+
bytes: bytes.length,
|
|
303
|
+
sha256: sha256(bytes),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
let current = null;
|
|
307
|
+
if (tool === 'edit' || fs.existsSync(file)) {
|
|
308
|
+
current = readText(file, bounds.maxFileBytes);
|
|
309
|
+
const seen = context.contents.get(target);
|
|
310
|
+
if (seen !== undefined && seen !== sha256(current.bytes)) {
|
|
311
|
+
return fail(
|
|
312
|
+
'changed since start read it; read it again before changing it'
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
} else if (
|
|
316
|
+
!fs.statSync(path.dirname(file), { throwIfNoEntry: false })?.isDirectory()
|
|
317
|
+
) {
|
|
318
|
+
return fail('its directory does not exist');
|
|
319
|
+
}
|
|
320
|
+
let updated;
|
|
321
|
+
let replacements = null;
|
|
322
|
+
if (tool === 'edit') {
|
|
323
|
+
if (args.oldString === '') {
|
|
324
|
+
return fail('old_string is empty');
|
|
325
|
+
}
|
|
326
|
+
if (args.oldString === args.newString) {
|
|
327
|
+
return fail('old_string and new_string are the same');
|
|
328
|
+
}
|
|
329
|
+
const found = occurrences(current.text, args.oldString);
|
|
330
|
+
if (!found.length) {
|
|
331
|
+
return fail('old_string was not found; nothing changed');
|
|
332
|
+
}
|
|
333
|
+
if (found.length > 1 && !args.replaceAll) {
|
|
334
|
+
return fail(
|
|
335
|
+
`old_string occurs ${found.length} times; give more surrounding text or set replace_all; nothing changed`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
replacements = args.replaceAll ? found.length : 1;
|
|
339
|
+
updated = args.replaceAll
|
|
340
|
+
? current.text.split(args.oldString).join(args.newString)
|
|
341
|
+
: `${current.text.slice(0, found[0])}${args.newString}${current.text.slice(found[0] + args.oldString.length)}`;
|
|
342
|
+
} else {
|
|
343
|
+
updated = args.content;
|
|
344
|
+
}
|
|
345
|
+
const expected = Buffer.from(updated, 'utf8');
|
|
346
|
+
if (expected.length > bounds.maxFileBytes) {
|
|
347
|
+
return fail(
|
|
348
|
+
`the new text has ${expected.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${bounds.maxFileBytes}`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
writeAtomically(file, expected, current ? current.mode : undefined);
|
|
352
|
+
const readback = fs.readFileSync(file);
|
|
353
|
+
const verified = readback.equals(expected);
|
|
354
|
+
context.observed.add(target);
|
|
355
|
+
context.contents.set(target, sha256(readback));
|
|
356
|
+
return {
|
|
357
|
+
exitCode: 0,
|
|
358
|
+
result: '',
|
|
359
|
+
bytes: expected.length,
|
|
360
|
+
sha256: sha256(readback),
|
|
361
|
+
previousSha256: current ? sha256(current.bytes) : null,
|
|
362
|
+
replacements,
|
|
363
|
+
verification: {
|
|
364
|
+
verified,
|
|
365
|
+
detail: verified
|
|
366
|
+
? `readback confirmed ${tool} ${target} (${expected.length} bytes)`
|
|
367
|
+
: `readback of ${target} differs from the written text`,
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error instanceof FileToolError) {
|
|
372
|
+
return fail(error.message);
|
|
373
|
+
}
|
|
374
|
+
return fail(`failed (${error.code || error.message})`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
module.exports = {
|
|
379
|
+
FILE_TOOLS,
|
|
380
|
+
describeFileCall,
|
|
381
|
+
evaluateFileCall,
|
|
382
|
+
normalizeFileArgs,
|
|
383
|
+
runFileCall,
|
|
384
|
+
};
|
|
@@ -15,9 +15,9 @@ const path = require('path');
|
|
|
15
15
|
/** Exit statuses of the mode; a finished task keeps its last tool's status. */
|
|
16
16
|
const EXIT = Object.freeze({
|
|
17
17
|
USAGE: 64, // request text or option combination rejected
|
|
18
|
-
UNSUPPORTED: 65, // Formal AI
|
|
18
|
+
UNSUPPORTED: 65, // Formal AI did not understand the task or reported a capability gap
|
|
19
19
|
UNAVAILABLE: 69, // no qualified Formal AI runtime could be resolved
|
|
20
|
-
PROTOCOL: 70, // malformed response, unknown tool or unverified effect
|
|
20
|
+
PROTOCOL: 70, // malformed or 5xx response, unknown tool or unverified effect
|
|
21
21
|
REFUSED: 77, // a tool call was refused by the effect policy
|
|
22
22
|
CONFIG: 78, // invalid START_FORMAL_AI_* bound
|
|
23
23
|
BUDGET: 124, // step, tool, time or retry budget exhausted
|
|
@@ -39,6 +39,11 @@ const BOUND_DEFAULTS = Object.freeze({
|
|
|
39
39
|
value: 64 * 1024,
|
|
40
40
|
max: 16 * 1024 * 1024,
|
|
41
41
|
},
|
|
42
|
+
maxFileBytes: {
|
|
43
|
+
env: 'START_FORMAL_AI_MAX_FILE_BYTES',
|
|
44
|
+
value: 1024 * 1024,
|
|
45
|
+
max: 16 * 1024 * 1024,
|
|
46
|
+
},
|
|
42
47
|
maxRequestChars: { env: 'START_FORMAL_AI_MAX_REQUEST_CHARS', value: 4096 },
|
|
43
48
|
maxMemoryMb: { env: 'START_FORMAL_AI_MAX_MEMORY_MB', value: 1024 },
|
|
44
49
|
maxFingerprintEntries: {
|
|
@@ -265,6 +270,8 @@ function operands(tokens) {
|
|
|
265
270
|
|
|
266
271
|
/**
|
|
267
272
|
* Normalized workspace-relative path of an operand, or null outside it.
|
|
273
|
+
* It uses `/` on every platform, like the fingerprint, so observations,
|
|
274
|
+
* messages and evidence are the same on Windows.
|
|
268
275
|
* @param {string} cwd - Working directory
|
|
269
276
|
* @param {string} operand - Operand text
|
|
270
277
|
* @returns {string|null}
|
|
@@ -275,7 +282,7 @@ function workspacePath(cwd, operand) {
|
|
|
275
282
|
if (!isInside(root, resolved)) {
|
|
276
283
|
return null;
|
|
277
284
|
}
|
|
278
|
-
return path.relative(root, resolved) || '.';
|
|
285
|
+
return path.relative(root, resolved).split(path.sep).join('/') || '.';
|
|
279
286
|
}
|
|
280
287
|
|
|
281
288
|
/**
|
package/src/lib/formal-ai.js
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* and keeps evidence of what actually happened. The run ends when Formal AI
|
|
8
8
|
* gives its final answer, says it did not understand, or a bound, refusal,
|
|
9
9
|
* protocol error or cancellation stops it, and the exit status says which.
|
|
10
|
+
* A final answer that is a known capability gap (issue #258) is reported as
|
|
11
|
+
* incomplete with its obligations, never as an answer.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
const policy = require('./formal-ai-policy');
|
|
13
15
|
const { formatToolResult, runTool } = require('./formal-ai-tool');
|
|
16
|
+
const files = require('./formal-ai-files');
|
|
17
|
+
const { recognizeCapabilityGap } = require('./formal-ai-client');
|
|
14
18
|
|
|
15
19
|
const { EXIT } = policy;
|
|
16
20
|
const PREVIEW_CHARS = 2000;
|
|
@@ -68,13 +72,15 @@ async function runFormalAiTask(options) {
|
|
|
68
72
|
effects: bounds.effects,
|
|
69
73
|
observed: new Set(),
|
|
70
74
|
observedDirs: new Set(),
|
|
75
|
+
contents: new Map(),
|
|
71
76
|
};
|
|
72
|
-
const finish = (status, exitCode, message, remaining = []) => {
|
|
77
|
+
const finish = (status, exitCode, message, remaining = [], extra = {}) => {
|
|
73
78
|
evidence.outcome = {
|
|
74
79
|
status,
|
|
75
80
|
exitCode,
|
|
76
81
|
message,
|
|
77
82
|
remainingObligations: remaining,
|
|
83
|
+
...extra,
|
|
78
84
|
};
|
|
79
85
|
return evidence;
|
|
80
86
|
};
|
|
@@ -119,9 +125,15 @@ async function runFormalAiTask(options) {
|
|
|
119
125
|
: error.exitCode === EXIT.UNAVAILABLE
|
|
120
126
|
? 'unavailable'
|
|
121
127
|
: 'protocol-error';
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
// The cause and attempt count of a runtime failure stay in the
|
|
129
|
+
// evidence (issue #257); they are sanitized and bounded by the client.
|
|
130
|
+
return finish(
|
|
131
|
+
status,
|
|
132
|
+
error.exitCode || EXIT.PROTOCOL,
|
|
133
|
+
error.message,
|
|
134
|
+
['Formal AI did not complete the task'],
|
|
135
|
+
error.details ? { diagnostics: { ...error.details } } : {}
|
|
136
|
+
);
|
|
125
137
|
}
|
|
126
138
|
evidence.steps.push({
|
|
127
139
|
kind: 'response',
|
|
@@ -135,6 +147,16 @@ async function runFormalAiTask(options) {
|
|
|
135
147
|
if (response.content) {
|
|
136
148
|
note(response.content);
|
|
137
149
|
}
|
|
150
|
+
const gap = recognizeCapabilityGap(response.content, messages);
|
|
151
|
+
if (gap) {
|
|
152
|
+
return finish(
|
|
153
|
+
'capability-gap',
|
|
154
|
+
EXIT.UNSUPPORTED,
|
|
155
|
+
describeGap(gap),
|
|
156
|
+
gapObligations(gap, bounds.effects),
|
|
157
|
+
{ gap }
|
|
158
|
+
);
|
|
159
|
+
}
|
|
138
160
|
if (response.handler === 'unknown' && toolCalls === 0) {
|
|
139
161
|
return finish(
|
|
140
162
|
'unsupported',
|
|
@@ -167,7 +189,10 @@ async function runFormalAiTask(options) {
|
|
|
167
189
|
type: 'function',
|
|
168
190
|
function: {
|
|
169
191
|
name: call.name,
|
|
170
|
-
arguments:
|
|
192
|
+
arguments:
|
|
193
|
+
call.tool === 'bash'
|
|
194
|
+
? JSON.stringify({ command: call.command })
|
|
195
|
+
: call.arguments,
|
|
171
196
|
},
|
|
172
197
|
})),
|
|
173
198
|
});
|
|
@@ -215,7 +240,139 @@ async function runFormalAiTask(options) {
|
|
|
215
240
|
);
|
|
216
241
|
}
|
|
217
242
|
|
|
218
|
-
|
|
243
|
+
/** Capability gap, in words. */
|
|
244
|
+
function describeGap(gap) {
|
|
245
|
+
const missing = gap.missingCapabilities.length
|
|
246
|
+
? `; missing ${gap.missingCapabilities.join(', ')}`
|
|
247
|
+
: '';
|
|
248
|
+
return `Formal AI reported a capability gap for ${gap.target} (${gap.reason}${missing}); nothing was verified as done`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function gapObligations(gap, effects) {
|
|
252
|
+
const obligations = [
|
|
253
|
+
`the requested change to ${gap.target} was not made or not verified`,
|
|
254
|
+
];
|
|
255
|
+
const changes = ['edit', 'write', 'multi_edit'];
|
|
256
|
+
if (
|
|
257
|
+
effects !== 'workspace-write' &&
|
|
258
|
+
gap.missingCapabilities.some((c) => changes.includes(c))
|
|
259
|
+
) {
|
|
260
|
+
obligations.push(
|
|
261
|
+
'set START_FORMAL_AI_EFFECTS=workspace-write to offer the edit and write tools'
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return obligations;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function runCall(options) {
|
|
268
|
+
return options.call.tool === 'bash'
|
|
269
|
+
? runBashCall(options)
|
|
270
|
+
: runFileToolCall(options);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Record a call's working-directory diff in its step and the run. */
|
|
274
|
+
function recordEffects(step, diff, evidence) {
|
|
275
|
+
step.effects = diff;
|
|
276
|
+
for (const key of ['added', 'removed', 'changed']) {
|
|
277
|
+
evidence.effects[key].push(...diff[key]);
|
|
278
|
+
}
|
|
279
|
+
evidence.effects.complete = evidence.effects.complete && diff.complete;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A structured read, edit or write call (issue #258). */
|
|
283
|
+
function runFileToolCall({ call, context, cwd, bounds, io, note, evidence }) {
|
|
284
|
+
const decision = files.evaluateFileCall(call.tool, call.args, context);
|
|
285
|
+
const step = {
|
|
286
|
+
kind: 'tool',
|
|
287
|
+
tool: call.tool,
|
|
288
|
+
command: call.command,
|
|
289
|
+
path: call.args.path,
|
|
290
|
+
};
|
|
291
|
+
evidence.steps.push(step);
|
|
292
|
+
if (!decision.allowed) {
|
|
293
|
+
step.decision = 'refused';
|
|
294
|
+
step.reason = decision.reason;
|
|
295
|
+
note(`refused \`${call.command}\`: ${decision.reason}`);
|
|
296
|
+
return {
|
|
297
|
+
stop: [
|
|
298
|
+
'refused',
|
|
299
|
+
EXIT.REFUSED,
|
|
300
|
+
`refused \`${call.command}\`: ${decision.reason}`,
|
|
301
|
+
['the refused call did not run; nothing after it ran either'],
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
Object.assign(step, {
|
|
306
|
+
decision: 'allowed',
|
|
307
|
+
effect: decision.kind,
|
|
308
|
+
target: decision.target,
|
|
309
|
+
});
|
|
310
|
+
note(call.command);
|
|
311
|
+
const started = Date.now();
|
|
312
|
+
const before = policy.fingerprint(cwd, bounds.maxFingerprintEntries);
|
|
313
|
+
const receipt = files.runFileCall(
|
|
314
|
+
call.tool,
|
|
315
|
+
call.args,
|
|
316
|
+
decision,
|
|
317
|
+
context,
|
|
318
|
+
bounds
|
|
319
|
+
);
|
|
320
|
+
const diff = policy.diffFingerprints(
|
|
321
|
+
before,
|
|
322
|
+
policy.fingerprint(cwd, bounds.maxFingerprintEntries)
|
|
323
|
+
);
|
|
324
|
+
Object.assign(step, {
|
|
325
|
+
exitCode: receipt.exitCode,
|
|
326
|
+
durationMs: Date.now() - started,
|
|
327
|
+
...(receipt.error ? { error: receipt.error } : {}),
|
|
328
|
+
...(receipt.bytes !== undefined ? { bytes: receipt.bytes } : {}),
|
|
329
|
+
...(receipt.sha256 ? { sha256: receipt.sha256 } : {}),
|
|
330
|
+
...(receipt.previousSha256
|
|
331
|
+
? { previousSha256: receipt.previousSha256 }
|
|
332
|
+
: {}),
|
|
333
|
+
...(receipt.replacements ? { replacements: receipt.replacements } : {}),
|
|
334
|
+
});
|
|
335
|
+
recordEffects(step, diff, evidence);
|
|
336
|
+
if (receipt.error) {
|
|
337
|
+
note(`${call.tool} failed: ${receipt.error}`);
|
|
338
|
+
} else if (call.tool === 'read') {
|
|
339
|
+
mirrorRead(receipt.text, bounds, io, note);
|
|
340
|
+
if (policy.hasEffects(diff)) {
|
|
341
|
+
step.unexpectedEffects = true;
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
step.verification = receipt.verification;
|
|
345
|
+
if (!receipt.verification.verified) {
|
|
346
|
+
return {
|
|
347
|
+
stop: [
|
|
348
|
+
'unverified',
|
|
349
|
+
EXIT.PROTOCOL,
|
|
350
|
+
`\`${call.command}\` wrote the file but ${receipt.verification.detail}`,
|
|
351
|
+
['the change could not be confirmed by readback'],
|
|
352
|
+
],
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
note(receipt.verification.detail);
|
|
356
|
+
}
|
|
357
|
+
return { exitCode: receipt.exitCode, result: receipt.result };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Show what a read returned, like the stdout of `cat`, within the output cap. */
|
|
361
|
+
function mirrorRead(text, bounds, io, note) {
|
|
362
|
+
if (!io.stdout || !text) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
366
|
+
const shown = bytes.subarray(0, bounds.maxOutputBytes).toString('utf8');
|
|
367
|
+
io.stdout.write(shown.endsWith('\n') ? shown : `${shown}\n`);
|
|
368
|
+
if (bytes.length > bounds.maxOutputBytes) {
|
|
369
|
+
note(
|
|
370
|
+
`output truncated at START_FORMAL_AI_MAX_OUTPUT_BYTES=${bounds.maxOutputBytes}`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function runBashCall({
|
|
219
376
|
call,
|
|
220
377
|
context,
|
|
221
378
|
cwd,
|
|
@@ -275,12 +432,8 @@ async function runCall({
|
|
|
275
432
|
spawnError: receipt.spawnError,
|
|
276
433
|
stdout: preview(receipt.stdout),
|
|
277
434
|
stderr: preview(receipt.stderr),
|
|
278
|
-
effects: diff,
|
|
279
435
|
});
|
|
280
|
-
|
|
281
|
-
evidence.effects[key].push(...diff[key]);
|
|
282
|
-
}
|
|
283
|
-
evidence.effects.complete = evidence.effects.complete && diff.complete;
|
|
436
|
+
recordEffects(step, diff, evidence);
|
|
284
437
|
if (receipt.truncated) {
|
|
285
438
|
note(
|
|
286
439
|
`output truncated at START_FORMAL_AI_MAX_OUTPUT_BYTES=${bounds.maxOutputBytes}`
|
package/src/lib/usage.js
CHANGED
|
@@ -105,7 +105,9 @@ Examples:
|
|
|
105
105
|
$ -i docker -d --on-kill-resume 3 --recovery-command 'solve --resume' -- solve
|
|
106
106
|
$ -i docker -d --on-kill-resume 3 --on-kill-resume-delay 30-90 -- cargo test
|
|
107
107
|
$ --use-command-stream echo "Hello" # Use command-stream library
|
|
108
|
-
$ --use-formal-ai -- print list of files in current directory
|
|
108
|
+
$ --use-formal-ai -- print list of files in current directory
|
|
109
|
+
$ --use-formal-ai -- show contents of README.md
|
|
110
|
+
START_FORMAL_AI_EFFECTS=workspace-write $ --use-formal-ai -- "In a.js, replace «x» with «y»."`);
|
|
109
111
|
console.log('');
|
|
110
112
|
console.log('Piping with $:');
|
|
111
113
|
console.log(' echo "hi" | $ agent # Preferred - pipe TO $ command');
|