start-command 0.40.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 +22 -0
- package/eslint.config.mjs +31 -0
- package/package.json +3 -3
- package/src/bin/cli.js +11 -1
- package/src/lib/args-parser.js +33 -0
- package/src/lib/formal-ai-cli.js +237 -0
- package/src/lib/formal-ai-client.js +390 -0
- package/src/lib/formal-ai-files.js +384 -0
- package/src/lib/formal-ai-policy.js +508 -0
- package/src/lib/formal-ai-runtime.js +304 -0
- package/src/lib/formal-ai-tool.js +182 -0
- package/src/lib/formal-ai.js +482 -0
- package/src/lib/usage.js +5 -1
- package/test/fixtures/formal-ai/fake-server.cjs +17 -0
- package/test/fixtures/formal-ai/helpers.cjs +189 -0
- package/test/fixtures/formal-ai/receipts-258.json +431 -0
- package/test/fixtures/formal-ai/receipts.json +776 -0
- package/test/fixtures/formal-ai/replay.cjs +194 -0
- package/test/regression-255-cli.js +296 -0
- package/test/regression-255-e2e.js +167 -0
- package/test/regression-255-loop.js +480 -0
- package/test/regression-255-policy.js +436 -0
- package/test/regression-257.js +265 -0
- package/test/regression-258-files.js +430 -0
- package/test/regression-258.js +443 -0
- package/test/shared-golden.js +3 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal client of the OpenAI-compatible agent-mode endpoint of Formal AI
|
|
3
|
+
* (`POST /api/openai/v1/chat/completions`), used by `--use-formal-ai`
|
|
4
|
+
* (issue #255). It offers the `bash` tool and the structured `read` tool, plus
|
|
5
|
+
* `edit` and `write` when workspace changes are allowed (issue #258): Formal
|
|
6
|
+
* AI picks the call, start decides whether and how it runs.
|
|
7
|
+
*
|
|
8
|
+
* An HTTP error keeps a bounded, sanitized excerpt of its body (the error
|
|
9
|
+
* type, message and a preview) and the attempt count, so a failing runtime
|
|
10
|
+
* names its cause (issue #257).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { EXIT } = require('./formal-ai-policy');
|
|
14
|
+
const { API_PREFIX } = require('./formal-ai-runtime');
|
|
15
|
+
const {
|
|
16
|
+
FILE_TOOLS,
|
|
17
|
+
describeFileCall,
|
|
18
|
+
normalizeFileArgs,
|
|
19
|
+
} = require('./formal-ai-files');
|
|
20
|
+
|
|
21
|
+
const { AbortSignal } = globalThis;
|
|
22
|
+
|
|
23
|
+
const BASH_NAMES = new Set(['bash', 'shell', 'run_command']);
|
|
24
|
+
const TOOL_NAMES = new Set([...BASH_NAMES, ...Object.keys(FILE_TOOLS)]);
|
|
25
|
+
const BASH_TOOL = Object.freeze({
|
|
26
|
+
type: 'function',
|
|
27
|
+
function: {
|
|
28
|
+
name: 'bash',
|
|
29
|
+
description:
|
|
30
|
+
'Run one command in the working directory. Pipes, redirects, globs and variables are not supported.',
|
|
31
|
+
parameters: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: { command: { type: 'string' } },
|
|
34
|
+
required: ['command'],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
class ProtocolError extends Error {
|
|
40
|
+
constructor(message, exitCode = EXIT.PROTOCOL, details = null) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.exitCode = exitCode;
|
|
43
|
+
this.details = details;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Tools offered to Formal AI: the change tools only when the effect mode
|
|
49
|
+
* allows changes, so the advertised contract matches the policy.
|
|
50
|
+
* @param {string} effects - Effect mode
|
|
51
|
+
* @returns {object[]}
|
|
52
|
+
*/
|
|
53
|
+
function offeredTools(effects) {
|
|
54
|
+
return effects === 'workspace-write'
|
|
55
|
+
? [BASH_TOOL, FILE_TOOLS.read, FILE_TOOLS.edit, FILE_TOOLS.write]
|
|
56
|
+
: [BASH_TOOL, FILE_TOOLS.read];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const PREVIEW_CHARS = 500;
|
|
60
|
+
const MESSAGE_CHARS = 300;
|
|
61
|
+
const REDACTED = '[REDACTED]';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One line of untrusted diagnostic text, bounded and without credentials:
|
|
65
|
+
* known secrets (the bearer token start sent), authorization headers, bearer
|
|
66
|
+
* or basic credentials and credential-named fields are masked, and control
|
|
67
|
+
* characters become spaces.
|
|
68
|
+
* @param {string} text - Diagnostic text from the runtime
|
|
69
|
+
* @param {number} limit - Maximum characters kept
|
|
70
|
+
* @param {string[]} [secrets] - Values that must never appear
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function sanitizeDiagnostic(text, limit, secrets = []) {
|
|
74
|
+
let clean = String(text);
|
|
75
|
+
for (const secret of secrets) {
|
|
76
|
+
if (secret) {
|
|
77
|
+
clean = clean.split(secret).join(REDACTED);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
clean = clean
|
|
81
|
+
.replace(
|
|
82
|
+
/((?:proxy-)?authorization["']?\s*[:=]\s*["']?)[^"'\r\n,;}]*/gi,
|
|
83
|
+
`$1${REDACTED}`
|
|
84
|
+
)
|
|
85
|
+
.replace(/\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{4,}/gi, `$1 ${REDACTED}`)
|
|
86
|
+
.replace(
|
|
87
|
+
/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd)["']?\s*[:=]\s*["']?)[^"'\s,;}]+/gi,
|
|
88
|
+
`$1${REDACTED}`
|
|
89
|
+
)
|
|
90
|
+
// eslint-disable-next-line no-control-regex
|
|
91
|
+
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
|
92
|
+
.trim();
|
|
93
|
+
return clean.length > limit
|
|
94
|
+
? `${clean.slice(0, limit)}… [${clean.length - limit} more chars]`
|
|
95
|
+
: clean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Structured diagnostics of an HTTP error response.
|
|
100
|
+
* @param {number} status - HTTP status
|
|
101
|
+
* @param {string} text - Response body
|
|
102
|
+
* @param {string[]} [secrets] - Values to mask
|
|
103
|
+
* @returns {{httpStatus: number, errorType: string|null, errorMessage: string|null, bodyPreview: string, bodyChars: number}}
|
|
104
|
+
*/
|
|
105
|
+
function httpErrorDetails(status, text, secrets = []) {
|
|
106
|
+
let type = null;
|
|
107
|
+
let message = null;
|
|
108
|
+
try {
|
|
109
|
+
const body = JSON.parse(text);
|
|
110
|
+
const error = body && typeof body === 'object' ? body.error : null;
|
|
111
|
+
if (error && typeof error === 'object') {
|
|
112
|
+
type = typeof error.type === 'string' ? error.type : null;
|
|
113
|
+
message = typeof error.message === 'string' ? error.message : null;
|
|
114
|
+
} else if (typeof error === 'string') {
|
|
115
|
+
message = error;
|
|
116
|
+
} else if (body && typeof body.message === 'string') {
|
|
117
|
+
message = body.message;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Not JSON: the preview carries the body.
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
httpStatus: status,
|
|
124
|
+
errorType: type === null ? null : sanitizeDiagnostic(type, 100, secrets),
|
|
125
|
+
errorMessage:
|
|
126
|
+
message === null
|
|
127
|
+
? null
|
|
128
|
+
: sanitizeDiagnostic(message, MESSAGE_CHARS, secrets),
|
|
129
|
+
bodyPreview: sanitizeDiagnostic(text, PREVIEW_CHARS, secrets),
|
|
130
|
+
bodyChars: text.length,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function httpErrorMessage(details) {
|
|
135
|
+
const cause = details.errorMessage
|
|
136
|
+
? `${details.errorType ? `${details.errorType}: ` : ''}${details.errorMessage}`
|
|
137
|
+
: details.bodyPreview.slice(0, MESSAGE_CHARS) || '(empty body)';
|
|
138
|
+
return `Formal AI returned HTTP ${details.httpStatus}: ${cause}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Known structured gaps Formal AI answers instead of claiming success. */
|
|
142
|
+
const GAP_SCHEMAS = Object.freeze({
|
|
143
|
+
'workspace-edit-capability-gap/v1': [
|
|
144
|
+
'EditToolUnavailable',
|
|
145
|
+
'ReadToolUnavailable',
|
|
146
|
+
],
|
|
147
|
+
'workspace-change-verification-gap/v1': ['VerificationRunToolUnavailable'],
|
|
148
|
+
});
|
|
149
|
+
const CAPABILITIES = new Set([
|
|
150
|
+
'read',
|
|
151
|
+
'edit',
|
|
152
|
+
'multi_edit',
|
|
153
|
+
'write',
|
|
154
|
+
'run',
|
|
155
|
+
'bash',
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Recognize a final answer that is exactly a known Formal AI capability gap.
|
|
160
|
+
* Only the runtime's own answer counts: text that a tool result in this run
|
|
161
|
+
* contained (file data echoed back) is never taken as a gap.
|
|
162
|
+
* @param {string} content - Final answer content
|
|
163
|
+
* @param {object[]} [messages] - Conversation so far
|
|
164
|
+
* @returns {object|null} {schema, target, reason, missingCapabilities}
|
|
165
|
+
*/
|
|
166
|
+
function recognizeCapabilityGap(content, messages = []) {
|
|
167
|
+
const text = typeof content === 'string' ? content.trim() : '';
|
|
168
|
+
if (!text.startsWith('{') || !text.endsWith('}')) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
let gap;
|
|
172
|
+
try {
|
|
173
|
+
gap = JSON.parse(text);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const reasons =
|
|
178
|
+
gap &&
|
|
179
|
+
typeof gap.schema === 'string' &&
|
|
180
|
+
Object.hasOwn(GAP_SCHEMAS, gap.schema)
|
|
181
|
+
? GAP_SCHEMAS[gap.schema]
|
|
182
|
+
: null;
|
|
183
|
+
const missing =
|
|
184
|
+
gap && gap.missingCapabilities === undefined
|
|
185
|
+
? []
|
|
186
|
+
: gap && gap.missingCapabilities;
|
|
187
|
+
if (
|
|
188
|
+
!reasons ||
|
|
189
|
+
!reasons.includes(gap.reason) ||
|
|
190
|
+
gap.complete !== false ||
|
|
191
|
+
gap.verified !== false ||
|
|
192
|
+
typeof gap.target !== 'string' ||
|
|
193
|
+
!gap.target.trim() ||
|
|
194
|
+
!Array.isArray(missing) ||
|
|
195
|
+
!missing.every((c) => CAPABILITIES.has(c))
|
|
196
|
+
) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const echoed = messages.some(
|
|
200
|
+
(m) => m && m.role === 'tool' && String(m.content || '').includes(text)
|
|
201
|
+
);
|
|
202
|
+
if (echoed) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
schema: gap.schema,
|
|
207
|
+
target: gap.target,
|
|
208
|
+
reason: gap.reason,
|
|
209
|
+
missingCapabilities: missing,
|
|
210
|
+
complete: false,
|
|
211
|
+
verified: false,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Normalize one chat completion into what the loop needs.
|
|
217
|
+
* @param {object} body - Parsed JSON response
|
|
218
|
+
* @returns {{content: string, finishReason: string|null, toolCalls: object[], handler: string|null}}
|
|
219
|
+
*/
|
|
220
|
+
function parseCompletion(body) {
|
|
221
|
+
const choice = body && Array.isArray(body.choices) ? body.choices[0] : null;
|
|
222
|
+
if (!choice || typeof choice.message !== 'object' || !choice.message) {
|
|
223
|
+
throw new ProtocolError('Formal AI response has no choices[0].message');
|
|
224
|
+
}
|
|
225
|
+
const { message } = choice;
|
|
226
|
+
const steps = Array.isArray(message.thinking_steps)
|
|
227
|
+
? message.thinking_steps
|
|
228
|
+
: [];
|
|
229
|
+
const dispatch = steps.find((s) => s && s.step === 'dispatch_handler');
|
|
230
|
+
const toolCalls = (message.tool_calls || []).map((call) => {
|
|
231
|
+
const name = call && call.function && call.function.name;
|
|
232
|
+
if (!TOOL_NAMES.has(name)) {
|
|
233
|
+
throw new ProtocolError(`Formal AI called an unsupported tool "${name}"`);
|
|
234
|
+
}
|
|
235
|
+
const raw = call.function.arguments || '{}';
|
|
236
|
+
let args;
|
|
237
|
+
try {
|
|
238
|
+
args = JSON.parse(raw);
|
|
239
|
+
} catch {
|
|
240
|
+
throw new ProtocolError(`Formal AI sent non-JSON arguments for ${name}`);
|
|
241
|
+
}
|
|
242
|
+
if (!BASH_NAMES.has(name)) {
|
|
243
|
+
const normalized = normalizeFileArgs(name, args);
|
|
244
|
+
if (normalized.problem) {
|
|
245
|
+
throw new ProtocolError(`Formal AI called ${normalized.problem}`);
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
id: call.id || null,
|
|
249
|
+
name,
|
|
250
|
+
tool: name,
|
|
251
|
+
args: normalized.args,
|
|
252
|
+
arguments: raw,
|
|
253
|
+
command: describeFileCall(name, normalized.args),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const command = args && (args.command ?? args.cmd);
|
|
257
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
258
|
+
throw new ProtocolError(`Formal AI called ${name} without a command`);
|
|
259
|
+
}
|
|
260
|
+
return { id: call.id || null, name, tool: 'bash', command };
|
|
261
|
+
});
|
|
262
|
+
return {
|
|
263
|
+
content: typeof message.content === 'string' ? message.content : '',
|
|
264
|
+
finishReason: choice.finish_reason || null,
|
|
265
|
+
toolCalls,
|
|
266
|
+
handler: dispatch ? String(dispatch.detail) : null,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Create a client bound to one runtime.
|
|
272
|
+
* @param {object} options - {baseUrl, bounds, deadline, token, tools}
|
|
273
|
+
* @returns {{complete: Function, requests: number}}
|
|
274
|
+
*/
|
|
275
|
+
function createClient({
|
|
276
|
+
baseUrl,
|
|
277
|
+
bounds,
|
|
278
|
+
deadline,
|
|
279
|
+
token = null,
|
|
280
|
+
tools = offeredTools(bounds.effects),
|
|
281
|
+
}) {
|
|
282
|
+
const secrets = token ? [token] : [];
|
|
283
|
+
const client = {
|
|
284
|
+
requests: 0,
|
|
285
|
+
async complete(messages, { signal } = {}) {
|
|
286
|
+
let lastError = null;
|
|
287
|
+
let attempts = 0;
|
|
288
|
+
for (let attempt = 0; attempt <= bounds.maxRetries; attempt++) {
|
|
289
|
+
const remaining = deadline - Date.now();
|
|
290
|
+
if (remaining <= 0) {
|
|
291
|
+
throw new ProtocolError(
|
|
292
|
+
'time budget exhausted while waiting for Formal AI',
|
|
293
|
+
EXIT.BUDGET
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
client.requests++;
|
|
297
|
+
attempts++;
|
|
298
|
+
try {
|
|
299
|
+
const response = await fetch(
|
|
300
|
+
`${baseUrl}${API_PREFIX}/chat/completions`,
|
|
301
|
+
{
|
|
302
|
+
method: 'POST',
|
|
303
|
+
headers: {
|
|
304
|
+
'content-type': 'application/json',
|
|
305
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
306
|
+
},
|
|
307
|
+
body: JSON.stringify({
|
|
308
|
+
model: 'formal-ai',
|
|
309
|
+
messages,
|
|
310
|
+
tools,
|
|
311
|
+
}),
|
|
312
|
+
signal: signal
|
|
313
|
+
? AbortSignal.any([signal, AbortSignal.timeout(remaining)])
|
|
314
|
+
: AbortSignal.timeout(remaining),
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
const text = await response.text();
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
const details = {
|
|
320
|
+
...httpErrorDetails(response.status, text, secrets),
|
|
321
|
+
attempts,
|
|
322
|
+
};
|
|
323
|
+
const error = new ProtocolError(
|
|
324
|
+
httpErrorMessage(details),
|
|
325
|
+
EXIT.PROTOCOL,
|
|
326
|
+
details
|
|
327
|
+
);
|
|
328
|
+
// A server error may be transient: retry it within the bounds,
|
|
329
|
+
// keeping the latest diagnostics.
|
|
330
|
+
if (response.status >= 500) {
|
|
331
|
+
lastError = error;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
let body;
|
|
337
|
+
try {
|
|
338
|
+
body = JSON.parse(text);
|
|
339
|
+
} catch {
|
|
340
|
+
throw new ProtocolError('Formal AI returned a non-JSON response');
|
|
341
|
+
}
|
|
342
|
+
return { raw: body, ...parseCompletion(body) };
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (error instanceof ProtocolError) {
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
if (signal && signal.aborted) {
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
if (error.name === 'TimeoutError') {
|
|
351
|
+
throw new ProtocolError(
|
|
352
|
+
'time budget exhausted while waiting for Formal AI',
|
|
353
|
+
EXIT.BUDGET
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const cause = sanitizeDiagnostic(
|
|
357
|
+
error.message,
|
|
358
|
+
MESSAGE_CHARS,
|
|
359
|
+
secrets
|
|
360
|
+
);
|
|
361
|
+
lastError = new ProtocolError(
|
|
362
|
+
`Formal AI request failed: ${cause}`,
|
|
363
|
+
EXIT.UNAVAILABLE,
|
|
364
|
+
{ cause, attempts }
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const retries = attempts - 1;
|
|
369
|
+
if (retries > 0) {
|
|
370
|
+
lastError.message += ` (after ${retries} retr${retries === 1 ? 'y' : 'ies'})`;
|
|
371
|
+
}
|
|
372
|
+
lastError.details.attempts = attempts;
|
|
373
|
+
throw lastError;
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
return client;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
module.exports = {
|
|
380
|
+
BASH_TOOL,
|
|
381
|
+
GAP_SCHEMAS,
|
|
382
|
+
ProtocolError,
|
|
383
|
+
TOOL_NAMES,
|
|
384
|
+
createClient,
|
|
385
|
+
httpErrorDetails,
|
|
386
|
+
offeredTools,
|
|
387
|
+
parseCompletion,
|
|
388
|
+
recognizeCapabilityGap,
|
|
389
|
+
sanitizeDiagnostic,
|
|
390
|
+
};
|