@molecule/api-ai-tools 1.0.6 → 1.0.7

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.
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAG9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAsBnE;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CA8qBxF"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAG9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAsBnE;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAyyBxF"}
package/dist/tools.js CHANGED
@@ -89,6 +89,102 @@ export function buildTools(backend, config) {
89
89
  result = isEnvFilePath(path) ? redactSecrets(result) : redactSecretsInCode(result);
90
90
  return result;
91
91
  }
92
+ // ── Empty-read verification ────────────────────────────────────
93
+ /**
94
+ * The file's size in bytes as the backend's own shell reports it, or `null`
95
+ * when that could not be established (the probe failed, timed out, or printed
96
+ * something unparseable).
97
+ *
98
+ * `null` means "I could not look" and is deliberately DISTINCT from `0` ("I
99
+ * looked and the file is empty") — conflating the two is the very failure this
100
+ * probe exists to prevent. The last integer in stdout is taken rather than the
101
+ * whole string, so a consumer that wraps commands in environment sourcing
102
+ * cannot break the parse with stray output.
103
+ *
104
+ * @param path - The already-resolved, symlink-checked path.
105
+ * @returns The byte count, or null when it could not be determined.
106
+ */
107
+ async function probeFileSize(path) {
108
+ try {
109
+ const result = await backend.run(`wc -c < ${shellQuote(path)}`, { timeout: 10_000 });
110
+ if (result.exitCode !== 0)
111
+ return null;
112
+ const match = /(\d+)\s*$/.exec(result.stdout);
113
+ if (!match)
114
+ return null;
115
+ const bytes = Number.parseInt(match[1], 10);
116
+ return Number.isFinite(bytes) ? bytes : null;
117
+ }
118
+ catch (_error) {
119
+ // The probe is the cross-check for an ALREADY-suspect read; if it cannot
120
+ // run we must report "unverified", never "empty" — returning 0 here would
121
+ // reintroduce exactly the conflation this function exists to remove.
122
+ return null;
123
+ }
124
+ }
125
+ /**
126
+ * Decide what an EMPTY string from `backend.readFile` actually means.
127
+ *
128
+ * A backend read can come back empty without throwing: a sandbox exec whose
129
+ * output stream ends early still reports the process's own exit code, an HTTP
130
+ * file read can return a 200 with no body, and a concurrent writer can be
131
+ * observed mid-truncate. Observed in production on 2026-09-16 (X0 rehearsal
132
+ * 84): several `read_file` calls on two real components returned no content
133
+ * while an exec shell showed both files present at 3,617 and 25,746 bytes; a
134
+ * later read of the same paths succeeded, so the failure was transient.
135
+ *
136
+ * Returning `{ content: '' }` for that is indistinguishable from a successful
137
+ * read of an empty file — and a model that believes a file is empty writes it
138
+ * from scratch, destroying work it never saw. So an empty read is verified
139
+ * against the file's real size before it is reported as content: genuinely
140
+ * empty is returned as empty and SAID so; non-empty is retried once and then
141
+ * reported as a FAILED read; unverifiable is also a failure, never an empty
142
+ * file.
143
+ *
144
+ * @param path - The already-resolved, symlink-checked path.
145
+ * @param tool - The calling tool's name, for the error message.
146
+ * @returns The file's real content (possibly empty, with a note), or an error.
147
+ */
148
+ async function classifyEmptyRead(path, tool) {
149
+ const bytes = await probeFileSize(path);
150
+ if (bytes === 0) {
151
+ return {
152
+ content: '',
153
+ note: `${path} exists and is EMPTY (0 bytes) — that is the file's real content, not a failed read.`,
154
+ };
155
+ }
156
+ if (bytes === null) {
157
+ return {
158
+ error: `${tool} obtained NO CONTENT for ${path}, and could not confirm the file's size ` +
159
+ `(the \`wc -c ${path}\` probe failed), so this may be a FAILED read rather than an ` +
160
+ `empty file. Do NOT write or edit ${path} from memory or from the plan — that would ` +
161
+ `overwrite a file you have never seen. Call read_file again, or run \`cat ${path}\` ` +
162
+ `with exec_command, and only proceed once you actually have its contents.`,
163
+ };
164
+ }
165
+ // Non-empty on disk: the read failed, and the observed failure is transient —
166
+ // so retry it once here rather than spending an executor turn on it.
167
+ try {
168
+ const retry = await backend.readFile(path);
169
+ if (typeof retry === 'string' && retry !== '') {
170
+ return {
171
+ content: retry,
172
+ note: `The first read of ${path} returned nothing; this content came from an immediate retry.`,
173
+ };
174
+ }
175
+ }
176
+ catch (_error) {
177
+ // The retry's own failure adds nothing to the message below — the size
178
+ // probe is the authoritative fact, and it already said the file has bytes.
179
+ }
180
+ return {
181
+ error: `${tool} obtained NO CONTENT for ${path}, but the file is ${bytes} bytes on disk — the ` +
182
+ `read FAILED, this is not an empty file (a retry returned nothing either). Do NOT write ` +
183
+ `or edit ${path} from memory or from the plan — that would overwrite a file you have ` +
184
+ `never seen. Call read_file again, or run \`cat ${path}\` with exec_command, and only ` +
185
+ `proceed once you actually have its contents.`,
186
+ };
187
+ }
92
188
  // ── Diff computation ───────────────────────────────────────────
93
189
  /**
94
190
  * Compute a lightweight diff summary for telemetry and UI badges.
@@ -134,6 +230,22 @@ export function buildTools(backend, config) {
134
230
  return { error: symlinkErr };
135
231
  try {
136
232
  const content = await backend.readFile(path);
233
+ // A read that produced no usable text is verified against the file's real
234
+ // size before it can be reported as content (see classifyEmptyRead). The
235
+ // non-string arm covers a backend whose transport handed back an empty
236
+ // body as `undefined`; `content.length` below would otherwise raise an
237
+ // unactionable "Cannot read properties of undefined".
238
+ if (typeof content !== 'string' || content === '') {
239
+ const verdict = await classifyEmptyRead(path, 'read_file');
240
+ if ('error' in verdict)
241
+ return verdict;
242
+ return {
243
+ path,
244
+ content: sanitizeFileContent(verdict.content, path),
245
+ ...(verdict.content === '' ? { empty: true } : {}),
246
+ ...(verdict.note ? { note: verdict.note } : {}),
247
+ };
248
+ }
137
249
  if (content.length > MAX_READ_SIZE)
138
250
  return {
139
251
  error: `File too large (${Math.round(content.length / 1024)}KB). Maximum is ${MAX_READ_SIZE / 1024 / 1024}MB.`,
@@ -251,6 +363,15 @@ export function buildTools(backend, config) {
251
363
  return { error: symlinkErr };
252
364
  try {
253
365
  let content = await backend.readFile(path);
366
+ // A transiently-empty read here made every old_string "not found", which
367
+ // steers the model to re-read and then rewrite a file it never saw. Same
368
+ // verification as read_file: empty is only believed once it is confirmed.
369
+ if (typeof content !== 'string' || content === '') {
370
+ const verdict = await classifyEmptyRead(path, 'edit_file');
371
+ if ('error' in verdict)
372
+ return verdict;
373
+ content = verdict.content;
374
+ }
254
375
  const oldContent = content;
255
376
  for (const { old_string: oldString, new_string: newString } of replacements) {
256
377
  // Validate each replacement is well-formed BEFORE touching content. A
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",