Skip to content

Commit 80828fc

Browse files
committed
🤖 fix: enforce single guard for file_edit_insert
Generated with
1 parent 549521c commit 80828fc

File tree

3 files changed

+91
-65
lines changed

3 files changed

+91
-65
lines changed

src/services/tools/file_edit_insert.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ describe("file_edit_insert tool", () => {
8080
}
8181
});
8282

83-
it("fails when before/after disagree", async () => {
83+
it("fails when both before and after are provided", async () => {
8484
const tool = createTestTool(testDir);
8585
const args: FileEditInsertToolArgs = {
8686
file_path: path.relative(testDir, testFilePath),
@@ -92,7 +92,7 @@ describe("file_edit_insert tool", () => {
9292
const result = (await tool.execute!(args, mockToolCallOptions)) as FileEditInsertToolResult;
9393
expect(result.success).toBe(false);
9494
if (!result.success) {
95-
expect(result.error).toContain("Guard mismatch");
95+
expect(result.error).toContain("only one of before or after");
9696
}
9797
});
9898

src/services/tools/file_edit_insert.ts

Lines changed: 83 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,27 @@ interface InsertOperationFailure {
2323
note?: string;
2424
}
2525

26+
interface InsertContentOptions {
27+
before?: string;
28+
after?: string;
29+
lineOffset?: number;
30+
}
31+
32+
interface GuardResolutionSuccess {
33+
success: true;
34+
index: number;
35+
}
36+
37+
function guardFailure(error: string): InsertOperationFailure {
38+
return {
39+
success: false,
40+
error,
41+
note: READ_AND_RETRY_NOTE,
42+
};
43+
}
44+
45+
type GuardAnchors = Pick<InsertContentOptions, "before" | "after">;
46+
2647
export const createFileEditInsertTool: ToolFactory = (config: ToolConfiguration) => {
2748
return tool({
2849
description: TOOL_DEFINITIONS.file_edit_insert.description,
@@ -108,18 +129,20 @@ export const createFileEditInsertTool: ToolFactory = (config: ToolConfiguration)
108129
function insertContent(
109130
originalContent: string,
110131
contentToInsert: string,
111-
{ before, after, lineOffset }: { before?: string; after?: string; lineOffset?: number }
132+
options: InsertContentOptions
112133
): InsertOperationSuccess | InsertOperationFailure {
134+
const { before, after, lineOffset } = options;
135+
136+
if (before !== undefined && after !== undefined) {
137+
return guardFailure("Provide only one of before or after (not both).");
138+
}
139+
113140
if (before !== undefined || after !== undefined) {
114141
return insertWithGuards(originalContent, contentToInsert, { before, after });
115142
}
116143

117144
if (lineOffset === undefined) {
118-
return {
119-
success: false,
120-
error: "line_offset must be provided when before/after guards are omitted.",
121-
note: READ_AND_RETRY_NOTE,
122-
};
145+
return guardFailure("line_offset must be provided when before/after guards are omitted.");
123146
}
124147

125148
return insertWithLineOffset(originalContent, contentToInsert, lineOffset);
@@ -128,46 +151,17 @@ function insertContent(
128151
function insertWithGuards(
129152
originalContent: string,
130153
contentToInsert: string,
131-
{ before, after }: { before?: string; after?: string }
154+
anchors: GuardAnchors
132155
): InsertOperationSuccess | InsertOperationFailure {
133-
let anchorIndex: number | undefined;
134-
135-
if (before !== undefined) {
136-
const beforeIndexResult = findUniqueSubstringIndex(originalContent, before, "before");
137-
if (!beforeIndexResult.success) {
138-
return beforeIndexResult;
139-
}
140-
anchorIndex = beforeIndexResult.index + before.length;
141-
}
142-
143-
if (after !== undefined) {
144-
const afterIndexResult = findUniqueSubstringIndex(originalContent, after, "after");
145-
if (!afterIndexResult.success) {
146-
return afterIndexResult;
147-
}
148-
149-
if (anchorIndex === undefined) {
150-
anchorIndex = afterIndexResult.index;
151-
} else if (anchorIndex !== afterIndexResult.index) {
152-
return {
153-
success: false,
154-
error:
155-
"Guard mismatch: before and after substrings do not point to the same insertion point.",
156-
note: READ_AND_RETRY_NOTE,
157-
};
158-
}
159-
}
160-
161-
if (anchorIndex === undefined) {
162-
return {
163-
success: false,
164-
error: "Unable to determine insertion point from guards.",
165-
note: READ_AND_RETRY_NOTE,
166-
};
156+
const anchorResult = resolveGuardAnchor(originalContent, anchors);
157+
if (!anchorResult.success) {
158+
return anchorResult;
167159
}
168160

169161
const newContent =
170-
originalContent.slice(0, anchorIndex) + contentToInsert + originalContent.slice(anchorIndex);
162+
originalContent.slice(0, anchorResult.index) +
163+
contentToInsert +
164+
originalContent.slice(anchorResult.index);
171165

172166
return {
173167
success: true,
@@ -180,49 +174,62 @@ function findUniqueSubstringIndex(
180174
haystack: string,
181175
needle: string,
182176
label: "before" | "after"
183-
): InsertOperationFailure | { success: true; index: number } {
177+
): GuardResolutionSuccess | InsertOperationFailure {
184178
const firstIndex = haystack.indexOf(needle);
185179
if (firstIndex === -1) {
186-
return {
187-
success: false,
188-
error: `Guard mismatch: unable to find ${label} substring in the current file.`,
189-
note: READ_AND_RETRY_NOTE,
190-
};
180+
return guardFailure(`Guard mismatch: unable to find ${label} substring in the current file.`);
191181
}
192182

193183
const secondIndex = haystack.indexOf(needle, firstIndex + needle.length);
194184
if (secondIndex !== -1) {
195-
return {
196-
success: false,
197-
error: `Guard mismatch: ${label} substring matched multiple times. Provide a more specific string.`,
198-
note: READ_AND_RETRY_NOTE,
199-
};
185+
return guardFailure(
186+
`Guard mismatch: ${label} substring matched multiple times. Provide a more specific string.`
187+
);
200188
}
201189

202190
return { success: true, index: firstIndex };
203191
}
204192

193+
function resolveGuardAnchor(
194+
originalContent: string,
195+
{ before, after }: GuardAnchors
196+
): GuardResolutionSuccess | InsertOperationFailure {
197+
if (before !== undefined) {
198+
const beforeIndexResult = findUniqueSubstringIndex(originalContent, before, "before");
199+
if (!beforeIndexResult.success) {
200+
return beforeIndexResult;
201+
}
202+
return { success: true, index: beforeIndexResult.index + before.length };
203+
}
204+
205+
if (after !== undefined) {
206+
const afterIndexResult = findUniqueSubstringIndex(originalContent, after, "after");
207+
if (!afterIndexResult.success) {
208+
return afterIndexResult;
209+
}
210+
return { success: true, index: afterIndexResult.index };
211+
}
212+
213+
return guardFailure("Unable to determine insertion point from guards.");
214+
}
215+
205216
function insertWithLineOffset(
206217
originalContent: string,
207218
contentToInsert: string,
208219
lineOffset: number
209220
): InsertOperationSuccess | InsertOperationFailure {
210-
const lines = originalContent.split("\n");
211-
if (lineOffset > lines.length) {
221+
const lineCount = countLines(originalContent);
222+
if (lineOffset > lineCount) {
212223
return {
213224
success: false,
214-
error: `line_offset ${lineOffset} is beyond file length (${lines.length} lines)`,
215-
note: `${EDIT_FAILED_NOTE_PREFIX} The file has ${lines.length} lines. ${NOTE_READ_FILE_RETRY}`,
225+
error: `line_offset ${lineOffset} is beyond file length (${lineCount} lines)`,
226+
note: `${EDIT_FAILED_NOTE_PREFIX} The file has ${lineCount} lines. ${NOTE_READ_FILE_RETRY}`,
216227
};
217228
}
218229

219230
const insertionIndex = getIndexForLineOffset(originalContent, lineOffset);
220231
if (insertionIndex === null) {
221-
return {
222-
success: false,
223-
error: `Unable to compute insertion point for line_offset ${lineOffset}.`,
224-
note: READ_AND_RETRY_NOTE,
225-
};
232+
return guardFailure(`Unable to compute insertion point for line_offset ${lineOffset}.`);
226233
}
227234

228235
const newContent =
@@ -264,3 +271,17 @@ function getIndexForLineOffset(content: string, lineOffset: number): number | nu
264271

265272
return null;
266273
}
274+
275+
function countLines(content: string): number {
276+
if (content.length === 0) {
277+
return 1;
278+
}
279+
280+
let count = 1;
281+
for (const char of content) {
282+
if (char === "\n") {
283+
count += 1;
284+
}
285+
}
286+
return count;
287+
}

src/utils/tools/toolDefinitions.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export const TOOL_DEFINITIONS = {
112112
description:
113113
"Insert content into a file using either a line offset or substring guards. " +
114114
"Provide at least one of before/after/line_offset so the operation is anchored. " +
115+
"When using guards, supply exactly one of before or after (not both). " +
115116
`Optional before/after substrings must uniquely match surrounding content. ${TOOL_EDIT_WARNING}`,
116117
schema: z
117118
.object({
@@ -148,7 +149,11 @@ export const TOOL_DEFINITIONS = {
148149
"Provide at least one of line_offset, before, or after to anchor the insertion point.",
149150
path: ["line_offset"],
150151
}
151-
),
152+
)
153+
.refine((data) => !(data.before !== undefined && data.after !== undefined), {
154+
message: "Provide only one of before or after (not both).",
155+
path: ["before"],
156+
}),
152157
},
153158
propose_plan: {
154159
description:

0 commit comments

Comments
 (0)