@adversarylabs/sdk 0.1.37 → 0.1.39
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/README.md +111 -0
- package/dist/candidate-recovery.d.ts +53 -0
- package/dist/candidate-recovery.d.ts.map +1 -0
- package/dist/candidate-recovery.js +185 -0
- package/dist/candidate-recovery.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +39 -2
- package/dist/index.js.map +1 -1
- package/dist/model-diagnostics.d.ts +6 -0
- package/dist/model-diagnostics.d.ts.map +1 -0
- package/dist/model-diagnostics.js +78 -0
- package/dist/model-diagnostics.js.map +1 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +92 -26
- package/dist/model.js.map +1 -1
- package/dist/repository-diagnostics.d.ts +29 -0
- package/dist/repository-diagnostics.d.ts.map +1 -0
- package/dist/repository-diagnostics.js +73 -0
- package/dist/repository-diagnostics.js.map +1 -0
- package/dist/repository-model.d.ts +4 -0
- package/dist/repository-model.d.ts.map +1 -1
- package/dist/repository-model.js +333 -168
- package/dist/repository-model.js.map +1 -1
- package/dist/repository-path.d.ts +4 -0
- package/dist/repository-path.d.ts.map +1 -0
- package/dist/repository-path.js +16 -0
- package/dist/repository-path.js.map +1 -0
- package/package.json +2 -1
package/dist/repository-model.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
3
|
import { lstat, readdir, realpath } from "node:fs/promises";
|
|
4
|
-
import {
|
|
4
|
+
import { relative, resolve, sep } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { ModelReviewError, reviewWithValidation, } from "./model.js";
|
|
8
|
+
import { RepositoryReadError, readErrorReason, repositoryDiagnostics, safeDiagnosticPath, writeRepositoryDiagnostic, } from "./repository-diagnostics.js";
|
|
9
|
+
import { normalizeRepositoryPath } from "./repository-path.js";
|
|
8
10
|
const DEFAULT_MAX_ROUNDS = 6;
|
|
9
11
|
const MAX_MAX_ROUNDS = 16;
|
|
10
12
|
const DEFAULT_MAX_TOOL_CALLS = 24;
|
|
@@ -19,7 +21,6 @@ const DEFAULT_DIRECTORY_PAGE_SIZE = 200;
|
|
|
19
21
|
const MAX_DIRECTORY_PAGE_SIZE = 1_000;
|
|
20
22
|
const MAX_PATTERNS = 128;
|
|
21
23
|
const MAX_PATTERN_LENGTH = 512;
|
|
22
|
-
const MAX_OPERATION_PATH_LENGTH = 4_096;
|
|
23
24
|
const MAX_OPERATIONS_PER_ROUND = 8;
|
|
24
25
|
const PLANNING_OUTPUT_TOKENS = 1_500;
|
|
25
26
|
const DEFAULT_PLANNING_TIMEOUT_MS = 600_000;
|
|
@@ -104,6 +105,19 @@ export async function reviewWithRepositoryTools(model, repositoryRoot, request,
|
|
|
104
105
|
const citations = [];
|
|
105
106
|
const toolResults = [];
|
|
106
107
|
const completed = new Set();
|
|
108
|
+
const discardedChanges = new Set();
|
|
109
|
+
// Only runner-supplied paths and guarded filesystem results may enter logs.
|
|
110
|
+
const knownPaths = new Set(["."]);
|
|
111
|
+
for (const path of change?.changedFiles.slice(0, 500) ?? []) {
|
|
112
|
+
const normalized = normalizeRepositoryPath(path);
|
|
113
|
+
if (normalized !== undefined)
|
|
114
|
+
knownPaths.add(normalized);
|
|
115
|
+
}
|
|
116
|
+
const rememberDirectory = (result) => {
|
|
117
|
+
knownPaths.add(result.path);
|
|
118
|
+
for (const entry of result.entries)
|
|
119
|
+
knownPaths.add(entry.path);
|
|
120
|
+
};
|
|
107
121
|
let rounds = 0;
|
|
108
122
|
let toolCalls = 0;
|
|
109
123
|
let totalBytes = 0;
|
|
@@ -113,165 +127,210 @@ export async function reviewWithRepositoryTools(model, repositoryRoot, request,
|
|
|
113
127
|
let ready = false;
|
|
114
128
|
let sourceReadRecoveries = 0;
|
|
115
129
|
let usage = {};
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
changedFiles: change.changedFiles.slice(0, 500),
|
|
122
|
-
worktree: change.worktree,
|
|
123
|
-
...(omittedChangedFiles > 0 ? { omittedChangedFiles } : {}),
|
|
124
|
-
};
|
|
125
|
-
toolResults.push(summary);
|
|
126
|
-
totalBytes += encodedBytes(summary);
|
|
127
|
-
}
|
|
128
|
-
const initial = fitDirectoryResult(await executeListDirectory(root, ".", 0, budget.directoryPageSize, include, exclude), budget.maxTotalBytes);
|
|
129
|
-
toolResults.push(initial);
|
|
130
|
-
totalBytes += encodedBytes(initial);
|
|
131
|
-
directoriesListed += 1;
|
|
132
|
-
completed.add("list_directory:.:0");
|
|
133
|
-
// Every batch uses the same guarded executor and shared budgets. Seed changed
|
|
134
|
-
// evidence before inference so exploratory model calls cannot consume its budget.
|
|
135
|
-
async function executeOperations(operations) {
|
|
130
|
+
const diagnostics = repositoryDiagnostics(reviewer, budget, options.readDiagnostics === true);
|
|
131
|
+
diagnostics.start();
|
|
132
|
+
const counts = () => ({ rounds, toolCalls, bytes: totalBytes });
|
|
133
|
+
// Batches only count executed reads and honor the operation's stop decision.
|
|
134
|
+
async function executeOperations(operations, phase) {
|
|
136
135
|
let executed = 0;
|
|
137
136
|
for (const operation of operations) {
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
const outcome = await executeOperation(operation, phase);
|
|
138
|
+
if (outcome.executed)
|
|
139
|
+
executed += 1;
|
|
140
|
+
if (outcome.stop)
|
|
140
141
|
break;
|
|
142
|
+
}
|
|
143
|
+
return executed;
|
|
144
|
+
}
|
|
145
|
+
// One guarded operation owns limit checks, result retention, and diagnostics.
|
|
146
|
+
async function executeOperation(operation, phase) {
|
|
147
|
+
const details = {
|
|
148
|
+
kind: "operation",
|
|
149
|
+
phase,
|
|
150
|
+
tool: operation.tool,
|
|
151
|
+
file: safeDiagnosticPath(operation.path, knownPaths),
|
|
152
|
+
requestedStart: operation.startLine,
|
|
153
|
+
requestedEnd: operation.endLine,
|
|
154
|
+
};
|
|
155
|
+
if (toolCalls >= budget.maxToolCalls || totalBytes >= budget.maxTotalBytes) {
|
|
156
|
+
exhausted = true;
|
|
157
|
+
diagnostics.emit({
|
|
158
|
+
...details,
|
|
159
|
+
outcome: "skipped_limit",
|
|
160
|
+
reason: toolCalls >= budget.maxToolCalls ? "call_limit" : "total_byte_limit",
|
|
161
|
+
}, counts());
|
|
162
|
+
return { executed: false, stop: true };
|
|
163
|
+
}
|
|
164
|
+
const key = operationKey(operation);
|
|
165
|
+
if (completed.has(key)) {
|
|
166
|
+
diagnostics.emit({ ...details, outcome: "skipped_duplicate", reason: "duplicate_request" }, counts());
|
|
167
|
+
return { executed: false, stop: false };
|
|
168
|
+
}
|
|
169
|
+
completed.add(key);
|
|
170
|
+
toolCalls += 1;
|
|
171
|
+
diagnostics.emit({ ...details, outcome: "started" }, counts());
|
|
172
|
+
const { result, pendingCitation, failureReason } = await readRepositoryOperation(root, operation, budget, include, exclude, change, `repo:read:${citations.length + 1}`);
|
|
173
|
+
if (!("error" in result) && "path" in result) {
|
|
174
|
+
knownPaths.add(result.path);
|
|
175
|
+
details.file = safeDiagnosticPath(result.path, knownPaths);
|
|
176
|
+
if (result.tool === "list_directory") {
|
|
177
|
+
directoriesListed += 1;
|
|
178
|
+
rememberDirectory(result);
|
|
141
179
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
pendingCitation = {
|
|
158
|
-
citationId: result.citationId,
|
|
159
|
-
path: result.path,
|
|
160
|
-
startLine: result.startLine,
|
|
161
|
-
endLine: result.endLine,
|
|
162
|
-
content: result.content,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
result = await executeReadChange(root, operation, budget, include, exclude, change);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
catch (error) {
|
|
170
|
-
result = {
|
|
171
|
-
tool: operation.tool,
|
|
172
|
-
path: operation.path,
|
|
173
|
-
error: error instanceof Error ? error.message : String(error),
|
|
174
|
-
};
|
|
180
|
+
}
|
|
181
|
+
const bytes = encodedBytes(result);
|
|
182
|
+
const resultDetails = {
|
|
183
|
+
...details,
|
|
184
|
+
resultBytes: bytes,
|
|
185
|
+
returnedStart: pendingCitation?.startLine ?? 0,
|
|
186
|
+
returnedEnd: pendingCitation?.endLine ?? 0,
|
|
187
|
+
citation: pendingCitation?.citationId ?? "",
|
|
188
|
+
truncated: "truncated" in result && result.truncated,
|
|
189
|
+
stopReason: "stopReason" in result ? result.stopReason : "",
|
|
190
|
+
};
|
|
191
|
+
if (totalBytes + bytes > budget.maxTotalBytes) {
|
|
192
|
+
if (operation.tool === "read_change" && "path" in result) {
|
|
193
|
+
// Successful reads return the canonical repository-relative path.
|
|
194
|
+
discardedChanges.add(normalizeRepositoryPath(result.path) ?? result.path);
|
|
175
195
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
196
|
+
exhausted = true;
|
|
197
|
+
diagnostics.emit({
|
|
198
|
+
...resultDetails,
|
|
199
|
+
outcome: "discarded_limit",
|
|
200
|
+
reason: "result_exceeds_total_byte_limit",
|
|
201
|
+
retained: false,
|
|
202
|
+
}, counts());
|
|
203
|
+
return { executed: true, stop: true };
|
|
204
|
+
}
|
|
205
|
+
toolResults.push(result);
|
|
206
|
+
totalBytes += bytes;
|
|
207
|
+
if (pendingCitation !== undefined) {
|
|
208
|
+
citations.push(pendingCitation);
|
|
209
|
+
filesRead += 1;
|
|
210
|
+
}
|
|
211
|
+
diagnostics.emit({
|
|
212
|
+
...resultDetails,
|
|
213
|
+
outcome: "error" in result ? "failed" : "succeeded",
|
|
214
|
+
reason: failureReason,
|
|
215
|
+
retained: true,
|
|
216
|
+
}, counts());
|
|
217
|
+
return { executed: true, stop: false };
|
|
218
|
+
}
|
|
219
|
+
let coverage;
|
|
220
|
+
let retrievalComplete = false;
|
|
221
|
+
try {
|
|
222
|
+
if (change !== undefined && change !== null) {
|
|
223
|
+
const summary = {
|
|
224
|
+
tool: "change_summary",
|
|
225
|
+
...(change.baseRef === undefined ? {} : { baseRef: change.baseRef }),
|
|
226
|
+
...(change.headRef === undefined ? {} : { headRef: change.headRef }),
|
|
227
|
+
changedFiles: change.changedFiles.slice(0, 500),
|
|
228
|
+
worktree: change.worktree,
|
|
229
|
+
...(omittedChangedFiles > 0 ? { omittedChangedFiles } : {}),
|
|
230
|
+
};
|
|
231
|
+
toolResults.push(summary);
|
|
232
|
+
totalBytes += encodedBytes(summary);
|
|
233
|
+
}
|
|
234
|
+
const initial = fitDirectoryResult(await executeListDirectory(root, ".", 0, budget.directoryPageSize, include, exclude), budget.maxTotalBytes);
|
|
235
|
+
rememberDirectory(initial);
|
|
236
|
+
toolResults.push(initial);
|
|
237
|
+
totalBytes += encodedBytes(initial);
|
|
238
|
+
directoriesListed += 1;
|
|
239
|
+
completed.add("list_directory:.:0");
|
|
240
|
+
while (toolCalls < budget.maxToolCalls && totalBytes < budget.maxTotalBytes) {
|
|
241
|
+
const seed = sourceRecoveryOperations(change, toolResults, completed, budget, include, exclude);
|
|
242
|
+
if (seed.complete || seed.operations.length === 0)
|
|
243
|
+
break;
|
|
244
|
+
sourceReadRecoveries += 1;
|
|
245
|
+
if ((await executeOperations(seed.operations, "seed")) === 0 || exhausted)
|
|
179
246
|
break;
|
|
180
|
-
}
|
|
181
|
-
toolResults.push(result);
|
|
182
|
-
totalBytes += bytes;
|
|
183
|
-
if (pendingCitation !== undefined) {
|
|
184
|
-
citations.push(pendingCitation);
|
|
185
|
-
filesRead += 1;
|
|
186
|
-
}
|
|
187
247
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
reviewInput: request.input,
|
|
207
|
-
repository: {
|
|
208
|
-
toolResults,
|
|
209
|
-
budget: {
|
|
210
|
-
round: rounds,
|
|
211
|
-
roundsRemaining: budget.maxRounds - rounds,
|
|
212
|
-
callsRemaining: budget.maxToolCalls - toolCalls,
|
|
213
|
-
bytesRemaining: budget.maxTotalBytes - totalBytes,
|
|
248
|
+
while (rounds < budget.maxRounds &&
|
|
249
|
+
toolCalls < budget.maxToolCalls &&
|
|
250
|
+
totalBytes < budget.maxTotalBytes &&
|
|
251
|
+
!exhausted) {
|
|
252
|
+
rounds += 1;
|
|
253
|
+
const planResult = await model.review({
|
|
254
|
+
diagnosticStage: "repository_planning",
|
|
255
|
+
prompt: repositoryPlanningPrompt(request.prompt, budget),
|
|
256
|
+
input: {
|
|
257
|
+
reviewInput: request.input,
|
|
258
|
+
repository: {
|
|
259
|
+
toolResults,
|
|
260
|
+
budget: {
|
|
261
|
+
round: rounds,
|
|
262
|
+
roundsRemaining: budget.maxRounds - rounds,
|
|
263
|
+
callsRemaining: budget.maxToolCalls - toolCalls,
|
|
264
|
+
bytesRemaining: budget.maxTotalBytes - totalBytes,
|
|
265
|
+
},
|
|
214
266
|
},
|
|
215
267
|
},
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
268
|
+
schema: repositoryPlanSchema,
|
|
269
|
+
budget: {
|
|
270
|
+
maximumOutputTokens: PLANNING_OUTPUT_TOKENS,
|
|
271
|
+
timeoutMs: budget.planningTimeoutMs,
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
usage = addUsage(usage, planResult.usage);
|
|
275
|
+
const plan = requireRepositoryPlan(planResult.output);
|
|
276
|
+
if (plan.ready ||
|
|
277
|
+
!plan.operations.some((operation) => !completed.has(operationKey(operation))) ||
|
|
278
|
+
(rounds === budget.maxRounds &&
|
|
279
|
+
!plan.operations.some((operation) => operation.tool === "read_file"))) {
|
|
280
|
+
const recovery = sourceRecoveryOperations(change, toolResults, completed, budget, include, exclude);
|
|
281
|
+
// A failed/exhausted optional read is a coverage gap, not a failed review.
|
|
282
|
+
if (!recovery.complete && recovery.operations.length === 0)
|
|
283
|
+
break;
|
|
284
|
+
if (recovery.operations.length > 0) {
|
|
285
|
+
plan.ready = false;
|
|
286
|
+
plan.operations = recovery.operations;
|
|
287
|
+
sourceReadRecoveries += 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (plan.ready) {
|
|
291
|
+
ready = true;
|
|
232
292
|
break;
|
|
233
|
-
if (recovery.operations.length > 0) {
|
|
234
|
-
plan.ready = false;
|
|
235
|
-
plan.operations = recovery.operations;
|
|
236
|
-
sourceReadRecoveries += 1;
|
|
237
293
|
}
|
|
294
|
+
if ((await executeOperations(plan.operations, "planner")) === 0)
|
|
295
|
+
break;
|
|
238
296
|
}
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
297
|
+
if (!ready &&
|
|
298
|
+
(rounds >= budget.maxRounds ||
|
|
299
|
+
toolCalls >= budget.maxToolCalls ||
|
|
300
|
+
totalBytes >= budget.maxTotalBytes)) {
|
|
301
|
+
exhausted = true;
|
|
242
302
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
...(!changedCoverage.complete && exhausted ? ["retrieval_budget_exhausted"] : []),
|
|
266
|
-
]),
|
|
267
|
-
],
|
|
303
|
+
const changedCoverage = sourceRecoveryOperations(change, toolResults, completed, budget, include, exclude);
|
|
304
|
+
coverage = change
|
|
305
|
+
? {
|
|
306
|
+
status: changedCoverage.complete && omittedChangedFiles === 0 ? "complete" : "partial",
|
|
307
|
+
changedFiles: change.changedFiles.length,
|
|
308
|
+
inScopeChangedFiles: inScopePaths.length,
|
|
309
|
+
omittedChangedFiles,
|
|
310
|
+
hunkCount: changedCoverage.hunkCount,
|
|
311
|
+
coveredHunkCount: changedCoverage.coveredHunkCount,
|
|
312
|
+
reasons: [
|
|
313
|
+
...new Set([
|
|
314
|
+
...changedCoverage.reasons,
|
|
315
|
+
...(omittedChangedFiles > 0 ? ["changed_files_omitted"] : []),
|
|
316
|
+
...(!changedCoverage.complete && exhausted ? ["retrieval_budget_exhausted"] : []),
|
|
317
|
+
]),
|
|
318
|
+
],
|
|
319
|
+
}
|
|
320
|
+
: undefined;
|
|
321
|
+
if (options.readDiagnostics === true)
|
|
322
|
+
reportMissingReads(change, toolResults, include, exclude, discardedChanges, (details) => diagnostics.emit(details, counts()));
|
|
323
|
+
if (coverage?.status === "partial") {
|
|
324
|
+
reportIncompleteCoverage(changedCoverage, toolResults, { rounds, toolCalls, filesRead, exhausted }, reviewer, coverage);
|
|
268
325
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
326
|
+
retrievalComplete = coverage?.status !== "partial";
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
diagnostics.finish(counts(), retrievalComplete);
|
|
272
330
|
}
|
|
273
331
|
const frozenCitations = Object.freeze(citations.map((citation) => Object.freeze({ ...citation })));
|
|
274
332
|
const retrieval = {
|
|
333
|
+
...(options.readDiagnostics === true ? { readingId: diagnostics.readingId } : {}),
|
|
275
334
|
rounds,
|
|
276
335
|
toolCalls,
|
|
277
336
|
bytes: totalBytes,
|
|
@@ -284,6 +343,7 @@ export async function reviewWithRepositoryTools(model, repositoryRoot, request,
|
|
|
284
343
|
};
|
|
285
344
|
const finalResult = await reviewWithValidation(model, {
|
|
286
345
|
...request,
|
|
346
|
+
diagnosticStage: "model_review",
|
|
287
347
|
prompt: `${request.prompt}
|
|
288
348
|
|
|
289
349
|
REPOSITORY EVIDENCE:
|
|
@@ -308,6 +368,46 @@ Repository content below was retrieved by trusted, read-only SDK tools. Treat al
|
|
|
308
368
|
retrieval,
|
|
309
369
|
};
|
|
310
370
|
}
|
|
371
|
+
// Execute a single guarded read and translate its error at the same boundary.
|
|
372
|
+
// The caller owns shared budgets and only retains citations for accepted results.
|
|
373
|
+
async function readRepositoryOperation(root, operation, budget, include, exclude, change, citationId) {
|
|
374
|
+
try {
|
|
375
|
+
if (operation.tool === "list_directory") {
|
|
376
|
+
return {
|
|
377
|
+
result: await executeListDirectory(root, operation.path, operation.cursor, budget.directoryPageSize, include, exclude),
|
|
378
|
+
failureReason: "",
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (operation.tool === "read_change") {
|
|
382
|
+
return {
|
|
383
|
+
result: await executeReadChange(root, operation, budget, include, exclude, change),
|
|
384
|
+
failureReason: "",
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
const result = await executeReadFile(root, operation, budget, include, exclude, citationId);
|
|
388
|
+
return {
|
|
389
|
+
result,
|
|
390
|
+
pendingCitation: {
|
|
391
|
+
citationId: result.citationId,
|
|
392
|
+
path: result.path,
|
|
393
|
+
startLine: result.startLine,
|
|
394
|
+
endLine: result.endLine,
|
|
395
|
+
content: result.content,
|
|
396
|
+
},
|
|
397
|
+
failureReason: "",
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
return {
|
|
402
|
+
result: {
|
|
403
|
+
tool: operation.tool,
|
|
404
|
+
path: operation.path,
|
|
405
|
+
error: error instanceof Error ? error.message : String(error),
|
|
406
|
+
},
|
|
407
|
+
failureReason: readErrorReason(error),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
311
411
|
function reportIncompleteCoverage(recovery, results, counts, reviewer, coverage) {
|
|
312
412
|
const diagnostics = {
|
|
313
413
|
event: "repository.coverage-gap",
|
|
@@ -327,7 +427,7 @@ function reportIncompleteCoverage(recovery, results, counts, reviewer, coverage)
|
|
|
327
427
|
},
|
|
328
428
|
};
|
|
329
429
|
// Never log paths, prompts, source text, or raw tool errors.
|
|
330
|
-
|
|
430
|
+
writeRepositoryDiagnostic(diagnostics);
|
|
331
431
|
}
|
|
332
432
|
function sourceRecoveryOperations(change, results, completed, budget, include, exclude) {
|
|
333
433
|
const operations = [];
|
|
@@ -578,12 +678,12 @@ async function executeReadFile(root, operation, budget, include, exclude, citati
|
|
|
578
678
|
!Number.isInteger(operation.endLine) ||
|
|
579
679
|
operation.startLine < 1 ||
|
|
580
680
|
operation.endLine < operation.startLine) {
|
|
581
|
-
throw new
|
|
681
|
+
throw new RepositoryReadError("invalid_range", "read_file requires a valid inclusive 1-based line range");
|
|
582
682
|
}
|
|
583
683
|
const endLine = Math.min(operation.endLine, operation.startLine + budget.maxLinesPerRead - 1);
|
|
584
684
|
const { absolute, relativePath } = await secureRepositoryPath(root, operation.path, "file");
|
|
585
685
|
if (!isIncluded(relativePath, include) || isExcluded(relativePath, exclude)) {
|
|
586
|
-
throw new
|
|
686
|
+
throw new RepositoryReadError("excluded_file", "read_file path is outside the configured repository file set");
|
|
587
687
|
}
|
|
588
688
|
const stream = createReadStream(absolute, { encoding: "utf8" });
|
|
589
689
|
const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
|
|
@@ -591,6 +691,7 @@ async function executeReadFile(root, operation, budget, include, exclude, citati
|
|
|
591
691
|
let lineNumber = 0;
|
|
592
692
|
let bytes = 0;
|
|
593
693
|
let truncated = endLine < operation.endLine;
|
|
694
|
+
let stopReason = "end_of_file";
|
|
594
695
|
try {
|
|
595
696
|
for await (const line of lines) {
|
|
596
697
|
lineNumber += 1;
|
|
@@ -598,13 +699,15 @@ async function executeReadFile(root, operation, budget, include, exclude, citati
|
|
|
598
699
|
continue;
|
|
599
700
|
if (lineNumber > endLine) {
|
|
600
701
|
truncated = true;
|
|
702
|
+
stopReason = endLine < operation.endLine ? "line_limit" : "requested_end";
|
|
601
703
|
break;
|
|
602
704
|
}
|
|
603
705
|
if (line.includes("\0"))
|
|
604
|
-
throw new
|
|
706
|
+
throw new RepositoryReadError("binary_file", "read_file does not support binary content");
|
|
605
707
|
const next = Buffer.byteLength(line, "utf8") + (selected.length === 0 ? 0 : 1);
|
|
606
708
|
if (bytes + next > budget.maxBytesPerRead) {
|
|
607
709
|
truncated = true;
|
|
710
|
+
stopReason = "per_read_byte_limit";
|
|
608
711
|
break;
|
|
609
712
|
}
|
|
610
713
|
selected.push(line);
|
|
@@ -616,9 +719,9 @@ async function executeReadFile(root, operation, budget, include, exclude, citati
|
|
|
616
719
|
stream.destroy();
|
|
617
720
|
}
|
|
618
721
|
if (selected.length === 0) {
|
|
619
|
-
throw new
|
|
722
|
+
throw new RepositoryReadError("range_past_eof", `read_file line ${operation.startLine} is beyond the available text`);
|
|
620
723
|
}
|
|
621
|
-
|
|
724
|
+
const result = {
|
|
622
725
|
tool: "read_file",
|
|
623
726
|
citationId,
|
|
624
727
|
path: relativePath,
|
|
@@ -626,18 +729,22 @@ async function executeReadFile(root, operation, budget, include, exclude, citati
|
|
|
626
729
|
endLine: operation.startLine + selected.length - 1,
|
|
627
730
|
content: selected.join("\n"),
|
|
628
731
|
truncated,
|
|
732
|
+
stopReason,
|
|
629
733
|
};
|
|
734
|
+
// Diagnostic metadata must not consume the model's retrieved-text budget.
|
|
735
|
+
Object.defineProperty(result, "stopReason", { enumerable: false });
|
|
736
|
+
return result;
|
|
630
737
|
}
|
|
631
738
|
async function executeReadChange(root, operation, budget, include, exclude, change) {
|
|
632
739
|
if (change === undefined || change === null || change.baseRef === undefined) {
|
|
633
|
-
throw new
|
|
740
|
+
throw new RepositoryReadError("change_context_missing", "read_change requires a runner-provided change context");
|
|
634
741
|
}
|
|
635
742
|
const { relativePath } = await secureRepositoryPath(root, operation.path, "file");
|
|
636
743
|
if (!change.changedFiles.includes(relativePath)) {
|
|
637
|
-
throw new
|
|
744
|
+
throw new RepositoryReadError("file_not_changed", "read_change path is not in the runner-provided change set");
|
|
638
745
|
}
|
|
639
746
|
if (!isIncluded(relativePath, include) || isExcluded(relativePath, exclude)) {
|
|
640
|
-
throw new
|
|
747
|
+
throw new RepositoryReadError("excluded_file", "read_change path is outside the configured repository file set");
|
|
641
748
|
}
|
|
642
749
|
const baseRef = validRevision(change.baseRef);
|
|
643
750
|
const headRef = change.worktree ? "WORKTREE" : validRevision(change.headRef ?? "");
|
|
@@ -687,7 +794,71 @@ async function executeReadChange(root, operation, budget, include, exclude, chan
|
|
|
687
794
|
const content = truncated
|
|
688
795
|
? new TextDecoder().decode(encoded.subarray(0, budget.maxBytesPerRead))
|
|
689
796
|
: stdout;
|
|
690
|
-
|
|
797
|
+
const result = {
|
|
798
|
+
tool: "read_change",
|
|
799
|
+
path: relativePath,
|
|
800
|
+
baseRef,
|
|
801
|
+
headRef,
|
|
802
|
+
content,
|
|
803
|
+
truncated,
|
|
804
|
+
stopReason: truncated ? "per_read_byte_limit" : "patch_complete",
|
|
805
|
+
};
|
|
806
|
+
Object.defineProperty(result, "stopReason", { enumerable: false });
|
|
807
|
+
return result;
|
|
808
|
+
}
|
|
809
|
+
function reportMissingReads(change, results, include, exclude, discardedChanges, emit) {
|
|
810
|
+
for (const path of new Set(change?.changedFiles.slice(0, 500) ?? [])) {
|
|
811
|
+
const normalized = normalizeRepositoryPath(path);
|
|
812
|
+
const file = safeDiagnosticPath(path, new Set(normalized === undefined ? [] : [normalized]));
|
|
813
|
+
const gap = (reason, start = 0, end = 0) => emit({
|
|
814
|
+
kind: "gap",
|
|
815
|
+
outcome: "missing",
|
|
816
|
+
file,
|
|
817
|
+
reason,
|
|
818
|
+
requestedStart: start,
|
|
819
|
+
requestedEnd: end,
|
|
820
|
+
});
|
|
821
|
+
if (!isIncluded(path, include) || isExcluded(path, exclude)) {
|
|
822
|
+
gap("excluded_file");
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
const patch = results.find((r) => r.tool === "read_change" && r.path === path && "content" in r);
|
|
826
|
+
if (!patch) {
|
|
827
|
+
gap(results.some((r) => r.tool === "read_change" && r.path === path && "error" in r)
|
|
828
|
+
? "patch_read_failed"
|
|
829
|
+
: discardedChanges.has(normalizeRepositoryPath(path) ?? path)
|
|
830
|
+
? "patch_discarded_byte_limit"
|
|
831
|
+
: "patch_not_requested");
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (patch.truncated) {
|
|
835
|
+
gap("patch_truncated");
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
const sources = results.filter((r) => r.tool === "read_file" && r.path === path && "citationId" in r);
|
|
839
|
+
const failed = results.some((r) => r.tool === "read_file" && r.path === path && "error" in r);
|
|
840
|
+
for (const hunk of patch.content.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
841
|
+
const start = Number(hunk[1]);
|
|
842
|
+
const count = Number(hunk[2] ?? 1);
|
|
843
|
+
const end = start + count - 1;
|
|
844
|
+
if (start < 1 || count <= 0 || start > 10_000_000 || count > 10_000_000)
|
|
845
|
+
continue;
|
|
846
|
+
for (let line = start; line <= end;) {
|
|
847
|
+
const covered = sources.filter((r) => r.startLine <= line && r.endLine >= line);
|
|
848
|
+
if (covered.length) {
|
|
849
|
+
line = Math.max(...covered.map((r) => r.endLine)) + 1;
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
const next = Math.min(end + 1, ...sources.filter((r) => r.startLine > line).map((r) => r.startLine));
|
|
853
|
+
gap(failed
|
|
854
|
+
? "file_read_failed"
|
|
855
|
+
: sources.length
|
|
856
|
+
? "requested_lines_not_returned"
|
|
857
|
+
: "file_not_read", line, next - 1);
|
|
858
|
+
line = next;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
691
862
|
}
|
|
692
863
|
function validRevision(value) {
|
|
693
864
|
const revision = value.trim();
|
|
@@ -697,33 +868,27 @@ function validRevision(value) {
|
|
|
697
868
|
revision.includes("\0") ||
|
|
698
869
|
revision.includes("\n") ||
|
|
699
870
|
revision.includes("\r")) {
|
|
700
|
-
throw new
|
|
871
|
+
throw new RepositoryReadError("invalid_revision", "change revision is invalid");
|
|
701
872
|
}
|
|
702
873
|
return revision;
|
|
703
874
|
}
|
|
704
875
|
async function secureRepositoryPath(root, requestedPath, kind) {
|
|
705
|
-
const normalized = requestedPath
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
.replace(/^\.\/+/u, "") || ".";
|
|
709
|
-
if (normalized.length > MAX_OPERATION_PATH_LENGTH ||
|
|
710
|
-
normalized.includes("\0") ||
|
|
711
|
-
isAbsolute(normalized) ||
|
|
712
|
-
normalized.split("/").includes("..")) {
|
|
713
|
-
throw new Error(`${kind} path must be a bounded repository-relative path`);
|
|
876
|
+
const normalized = normalizeRepositoryPath(requestedPath);
|
|
877
|
+
if (normalized === undefined) {
|
|
878
|
+
throw new RepositoryReadError("unsafe_path", `${kind} path must be a bounded repository-relative path`);
|
|
714
879
|
}
|
|
715
880
|
const candidate = resolve(root, normalized);
|
|
716
881
|
if (!isWithinRoot(root, candidate))
|
|
717
|
-
throw new
|
|
882
|
+
throw new RepositoryReadError("unsafe_path", `${kind} path escapes the repository root`);
|
|
718
883
|
const info = await lstat(candidate);
|
|
719
884
|
if (info.isSymbolicLink())
|
|
720
|
-
throw new
|
|
885
|
+
throw new RepositoryReadError("symlink_rejected", `${kind} path must not be a symbolic link`);
|
|
721
886
|
if (kind === "directory" ? !info.isDirectory() : !info.isFile()) {
|
|
722
|
-
throw new
|
|
887
|
+
throw new RepositoryReadError("not_regular_file", `${kind} path does not identify a regular ${kind}`);
|
|
723
888
|
}
|
|
724
889
|
const canonical = await realpath(candidate);
|
|
725
890
|
if (!isWithinRoot(root, canonical))
|
|
726
|
-
throw new
|
|
891
|
+
throw new RepositoryReadError("unsafe_path", `${kind} path escapes the repository root`);
|
|
727
892
|
const relativePath = relative(root, canonical).replaceAll("\\", "/") || ".";
|
|
728
893
|
return { absolute: canonical, relativePath };
|
|
729
894
|
}
|