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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # start-command
2
2
 
3
+ ## 0.42.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5ebb7ec: `--use-formal-ai`: keep the cause of Formal AI server errors, offer structured file tools, and report capability gaps as incomplete (#257, #258, #259).
8
+
9
+ - **Server errors (#257):** an HTTP error from the Formal AI runtime now keeps its sanitized, bounded cause: the error type (100 chars), the message (300), a body preview (500), the body length and the number of attempts. The cause appears in the final message (`Formal AI returned HTTP 500: formal_ai_error: Aborted(...) (after 1 retry)`) and under `outcome.diagnostics` in the evidence. Authorization values, credentials, control characters and the environment are never recorded. There is still no fallback, the exit status is still 70, and 4xx responses are not retried.
10
+ - **File tools (#258):** next to `bash`, Formal AI is offered a structured `read` tool, plus `edit` (one literal replacement) and `write` (replace a file it has read) in `START_FORMAL_AI_EFFECTS=workspace-write` mode. The tools follow the workspace path policy: no paths outside the working directory, and a change only to a file read in the same task that has not changed since. They keep comments, whitespace, line endings, a byte order mark, the file mode and all unrelated content. Each change is checked by reading the file back, and the evidence records its hashes and effects. Files over `START_FORMAL_AI_MAX_FILE_BYTES` (1 MiB by default, at most 16 MiB) are refused, and interpreters are still not offered.
11
+ - **Capability gaps (#258):** an answer that matches one of Formal AI's known capability-gap records (`workspace-edit-capability-gap/v1` or `workspace-change-verification-gap/v1`, with `complete` and `verified` both `false`) is now reported as `capability-gap` with exit 65. Its remaining obligations are kept. Plain answers to questions still exit 0.
12
+
3
13
  ## 0.41.0
4
14
 
5
15
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -70,7 +70,7 @@
70
70
  "dependencies": {
71
71
  "@link-foundation/sensitive-data-sanitizer": "^0.12.2",
72
72
  "command-stream": "^2.6.0",
73
- "lino-objects-codec": "^0.9.4"
73
+ "lino-objects-codec": "^0.9.5"
74
74
  },
75
75
  "overrides": {
76
76
  "brace-expansion": "^5.0.12",
@@ -1,16 +1,27 @@
1
1
  /**
2
2
  * Minimal client of the OpenAI-compatible agent-mode endpoint of Formal AI
3
3
  * (`POST /api/openai/v1/chat/completions`), used by `--use-formal-ai`
4
- * (issue #255). Only the `bash` function tool is offered: Formal AI picks the
5
- * command, start decides whether and how it runs.
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).
6
11
  */
7
12
 
8
13
  const { EXIT } = require('./formal-ai-policy');
9
14
  const { API_PREFIX } = require('./formal-ai-runtime');
15
+ const {
16
+ FILE_TOOLS,
17
+ describeFileCall,
18
+ normalizeFileArgs,
19
+ } = require('./formal-ai-files');
10
20
 
11
21
  const { AbortSignal } = globalThis;
12
22
 
13
- const TOOL_NAMES = new Set(['bash', 'shell', 'run_command']);
23
+ const BASH_NAMES = new Set(['bash', 'shell', 'run_command']);
24
+ const TOOL_NAMES = new Set([...BASH_NAMES, ...Object.keys(FILE_TOOLS)]);
14
25
  const BASH_TOOL = Object.freeze({
15
26
  type: 'function',
16
27
  function: {
@@ -26,12 +37,181 @@ const BASH_TOOL = Object.freeze({
26
37
  });
27
38
 
28
39
  class ProtocolError extends Error {
29
- constructor(message, exitCode = EXIT.PROTOCOL) {
40
+ constructor(message, exitCode = EXIT.PROTOCOL, details = null) {
30
41
  super(message);
31
42
  this.exitCode = exitCode;
43
+ this.details = details;
32
44
  }
33
45
  }
34
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
+
35
215
  /**
36
216
  * Normalize one chat completion into what the loop needs.
37
217
  * @param {object} body - Parsed JSON response
@@ -52,17 +232,32 @@ function parseCompletion(body) {
52
232
  if (!TOOL_NAMES.has(name)) {
53
233
  throw new ProtocolError(`Formal AI called an unsupported tool "${name}"`);
54
234
  }
235
+ const raw = call.function.arguments || '{}';
55
236
  let args;
56
237
  try {
57
- args = JSON.parse(call.function.arguments || '{}');
238
+ args = JSON.parse(raw);
58
239
  } catch {
59
240
  throw new ProtocolError(`Formal AI sent non-JSON arguments for ${name}`);
60
241
  }
61
- const command = args.command ?? args.cmd;
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);
62
257
  if (typeof command !== 'string' || !command.trim()) {
63
258
  throw new ProtocolError(`Formal AI called ${name} without a command`);
64
259
  }
65
- return { id: call.id || null, name, command };
260
+ return { id: call.id || null, name, tool: 'bash', command };
66
261
  });
67
262
  return {
68
263
  content: typeof message.content === 'string' ? message.content : '',
@@ -74,14 +269,22 @@ function parseCompletion(body) {
74
269
 
75
270
  /**
76
271
  * Create a client bound to one runtime.
77
- * @param {object} options - {baseUrl, bounds, deadline, token}
272
+ * @param {object} options - {baseUrl, bounds, deadline, token, tools}
78
273
  * @returns {{complete: Function, requests: number}}
79
274
  */
80
- function createClient({ baseUrl, bounds, deadline, token = null }) {
275
+ function createClient({
276
+ baseUrl,
277
+ bounds,
278
+ deadline,
279
+ token = null,
280
+ tools = offeredTools(bounds.effects),
281
+ }) {
282
+ const secrets = token ? [token] : [];
81
283
  const client = {
82
284
  requests: 0,
83
285
  async complete(messages, { signal } = {}) {
84
286
  let lastError = null;
287
+ let attempts = 0;
85
288
  for (let attempt = 0; attempt <= bounds.maxRetries; attempt++) {
86
289
  const remaining = deadline - Date.now();
87
290
  if (remaining <= 0) {
@@ -91,6 +294,7 @@ function createClient({ baseUrl, bounds, deadline, token = null }) {
91
294
  );
92
295
  }
93
296
  client.requests++;
297
+ attempts++;
94
298
  try {
95
299
  const response = await fetch(
96
300
  `${baseUrl}${API_PREFIX}/chat/completions`,
@@ -103,7 +307,7 @@ function createClient({ baseUrl, bounds, deadline, token = null }) {
103
307
  body: JSON.stringify({
104
308
  model: 'formal-ai',
105
309
  messages,
106
- tools: [BASH_TOOL],
310
+ tools,
107
311
  }),
108
312
  signal: signal
109
313
  ? AbortSignal.any([signal, AbortSignal.timeout(remaining)])
@@ -111,16 +315,23 @@ function createClient({ baseUrl, bounds, deadline, token = null }) {
111
315
  }
112
316
  );
113
317
  const text = await response.text();
114
- if (response.status >= 500) {
115
- lastError = new ProtocolError(
116
- `Formal AI returned HTTP ${response.status}`
117
- );
118
- continue;
119
- }
120
318
  if (!response.ok) {
121
- throw new ProtocolError(
122
- `Formal AI returned HTTP ${response.status}: ${text.slice(0, 300)}`
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
123
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;
124
335
  }
125
336
  let body;
126
337
  try {
@@ -142,15 +353,23 @@ function createClient({ baseUrl, bounds, deadline, token = null }) {
142
353
  EXIT.BUDGET
143
354
  );
144
355
  }
356
+ const cause = sanitizeDiagnostic(
357
+ error.message,
358
+ MESSAGE_CHARS,
359
+ secrets
360
+ );
145
361
  lastError = new ProtocolError(
146
- `Formal AI request failed: ${error.message}`,
147
- EXIT.UNAVAILABLE
362
+ `Formal AI request failed: ${cause}`,
363
+ EXIT.UNAVAILABLE,
364
+ { cause, attempts }
148
365
  );
149
366
  }
150
367
  }
151
- if (bounds.maxRetries > 0) {
152
- lastError.message += ` (after ${bounds.maxRetries} retr${bounds.maxRetries === 1 ? 'y' : 'ies'})`;
368
+ const retries = attempts - 1;
369
+ if (retries > 0) {
370
+ lastError.message += ` (after ${retries} retr${retries === 1 ? 'y' : 'ies'})`;
153
371
  }
372
+ lastError.details.attempts = attempts;
154
373
  throw lastError;
155
374
  },
156
375
  };
@@ -159,8 +378,13 @@ function createClient({ baseUrl, bounds, deadline, token = null }) {
159
378
 
160
379
  module.exports = {
161
380
  BASH_TOOL,
381
+ GAP_SCHEMAS,
162
382
  ProtocolError,
163
383
  TOOL_NAMES,
164
384
  createClient,
385
+ httpErrorDetails,
386
+ offeredTools,
165
387
  parseCompletion,
388
+ recognizeCapabilityGap,
389
+ sanitizeDiagnostic,
166
390
  };