Skip to content

Commit 1bd71b4

Browse files
committed
Treat edit_file filler args as absent for mode selection (CL-6900)
Models pad the unused mode's fields with fillers (old_string: "", start_line: 0), which made parseEditFileMode see both edit modes and reject the call; models then retried the identical call up to 8x (76% of gpt-5.6-terra edit_file calls in production traces). Mode selection now ignores fillers: line fields that are null, non-integer, or < 1 count as absent, and so does an empty old_string. A real mixed-mode call is still rejected (CL-4399), but the error now echoes the received values and names the fields to drop so a retry can differ, and the missing-mode error states what was received.
1 parent bebe563 commit 1bd71b4

2 files changed

Lines changed: 126 additions & 37 deletions

File tree

‎src/plugins/edit-file-line-range.test.ts‎

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,65 @@ describe("parseEditFileMode", () => {
5252
expect(mode.message).toContain("only one edit mode is allowed");
5353
expect(mode.message).toContain("Omit old_string");
5454
expect(mode.message).toContain("omit start_line/end_line");
55+
expect(mode.message).toContain("old_string (len 1)");
56+
expect(mode.message).toContain("start_line=1");
57+
expect(mode.message).toContain("end_line=1");
58+
}
59+
});
60+
61+
test("treats filler start_line/end_line of 0 as absent and picks substring mode", () => {
62+
const mode = parseEditFileMode({
63+
path: "a.ts",
64+
old_string: "x",
65+
new_string: "y",
66+
start_line: 0,
67+
end_line: 0,
68+
});
69+
expect(mode.kind).toBe("substring");
70+
});
71+
72+
test("treats null line fields as absent and picks substring mode", () => {
73+
const mode = parseEditFileMode({
74+
path: "a.ts",
75+
old_string: "x",
76+
new_string: "y",
77+
start_line: null,
78+
end_line: null,
79+
});
80+
expect(mode.kind).toBe("substring");
81+
});
82+
83+
test("treats filler empty old_string as absent and picks line-range mode", () => {
84+
const mode = parseEditFileMode({
85+
path: "a.ts",
86+
old_string: "",
87+
start_line: 2,
88+
end_line: 3,
89+
new_string: "z",
90+
});
91+
expect(mode.kind).toBe("line_range");
92+
});
93+
94+
test("empty old_string alone gets a helpful error naming what was received", () => {
95+
const mode = parseEditFileMode({
96+
path: "a.ts",
97+
old_string: "",
98+
new_string: "y",
99+
});
100+
expect(mode.kind).toBe("invalid");
101+
if (mode.kind === "invalid") {
102+
expect(mode.message).toContain("old_string is empty");
103+
expect(mode.message).toContain("old_string (len 0)");
104+
}
105+
});
106+
107+
test("missing both modes reports what was received", () => {
108+
const mode = parseEditFileMode({ path: "a.ts", new_string: "y" });
109+
expect(mode.kind).toBe("invalid");
110+
if (mode.kind === "invalid") {
111+
expect(mode.message).toContain("requires old_string");
112+
expect(mode.message).toContain("no old_string");
113+
expect(mode.message).toContain("no start_line");
55114
}
56115
});
57116

@@ -145,7 +204,11 @@ describe("advertiseEditFileLineRange", () => {
145204
});
146205

147206
test("leaves non-edit tools unchanged", () => {
148-
const def = { name: "read_file", description: "r", inputSchema: { type: "object", properties: {} } };
207+
const def = {
208+
name: "read_file",
209+
description: "r",
210+
inputSchema: { type: "object", properties: {} },
211+
};
149212
expect(advertiseEditFileLineRange(def)).toBe(def);
150213
});
151214
});
@@ -162,11 +225,9 @@ describe("editFileLineRangePlugin", () => {
162225
});
163226

164227
function handler(next: (call: ToolCall, signal: AbortSignal) => Promise<ToolResult>) {
165-
const mws = [
166-
pathEscapePlugin(cwd).middleware!,
167-
editFileLineRangePlugin().middleware!,
168-
verifyPlugin().middleware!,
169-
];
228+
const mws = [pathEscapePlugin(cwd), editFileLineRangePlugin(), verifyPlugin()].flatMap((p) =>
229+
p.middleware ? [p.middleware] : [],
230+
);
170231
return composeMiddleware(mws, next);
171232
}
172233

‎src/plugins/edit-file-line-range.ts‎

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,67 @@ import { readFile, writeFile } from "node:fs/promises";
22
import { hasCode } from "@intx/types";
33
import type { ToolDefinition } from "@intx/types/runtime";
44

5-
export type EditFileSubstringMode = {
5+
export interface EditFileSubstringMode {
66
kind: "substring";
77
path: string;
88
old_string: string;
99
new_string: string;
1010
replace_all: boolean;
11-
};
11+
}
1212

13-
export type EditFileLineRangeMode = {
13+
export interface EditFileLineRangeMode {
1414
kind: "line_range";
1515
path: string;
1616
start_line: number;
1717
end_line: number;
1818
new_string: string;
19-
};
19+
}
2020

2121
export type EditFileModeParse =
22-
| EditFileSubstringMode
23-
| EditFileLineRangeMode
24-
| { kind: "invalid"; message: string };
22+
EditFileSubstringMode | EditFileLineRangeMode | { kind: "invalid"; message: string };
2523

2624
function optionalInt(value: unknown): number | undefined {
2725
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value)
2826
? value
2927
: undefined;
3028
}
3129

30+
// Models pad the unused mode's fields with fillers (old_string: "", start_line: 0).
31+
// For mode selection a filler counts as absent: "" is never a valid old_string and
32+
// 0/null/non-integers are never valid 1-based lines. Treating them as present made
33+
// filler-padded calls look like "both edit modes" and rejected them (CL-6900).
3234
function hasOldStringArg(args: Record<string, unknown>): boolean {
33-
return typeof args.old_string === "string";
35+
return typeof args.old_string === "string" && args.old_string.length > 0;
36+
}
37+
38+
function validLineNumber(value: unknown): number | undefined {
39+
const n = optionalInt(value);
40+
return n !== undefined && n >= 1 ? n : undefined;
3441
}
3542

3643
function hasLineRangeArgs(args: Record<string, unknown>): boolean {
37-
return optionalInt(args.start_line) !== undefined || optionalInt(args.end_line) !== undefined;
44+
return (
45+
validLineNumber(args.start_line) !== undefined || validLineNumber(args.end_line) !== undefined
46+
);
3847
}
3948

40-
const MIXED_MODE_MESSAGE =
41-
"edit_file: received both old_string and start_line/end_line; only one edit mode is allowed. " +
42-
"Omit old_string to use line-range mode, or omit start_line/end_line to use substring mode.";
49+
function describeReceived(args: Record<string, unknown>): string {
50+
const old = args.old_string;
51+
return [
52+
typeof old === "string" ? `old_string (len ${old.length})` : "no old_string",
53+
args.start_line === undefined
54+
? "no start_line"
55+
: `start_line=${JSON.stringify(args.start_line)}`,
56+
args.end_line === undefined ? "no end_line" : `end_line=${JSON.stringify(args.end_line)}`,
57+
].join(", ");
58+
}
59+
60+
function mixedModeMessage(args: Record<string, unknown>): string {
61+
return (
62+
`edit_file: received ${describeReceived(args)}; only one edit mode is allowed. ` +
63+
"Omit old_string to use line-range mode, or omit start_line/end_line to use substring mode."
64+
);
65+
}
4366

4467
export function parseLineRangeFields(
4568
path: string,
@@ -51,14 +74,18 @@ export function parseLineRangeFields(
5174
if (start_line === undefined || end_line === undefined) {
5275
return {
5376
kind: "invalid",
54-
message: "edit_file line-range mode requires both start_line and end_line (1-based inclusive)",
77+
message:
78+
"edit_file line-range mode requires both start_line and end_line (1-based inclusive)",
5579
};
5680
}
5781
if (start_line < 1 || end_line < 1) {
5882
return { kind: "invalid", message: "start_line and end_line must be >= 1" };
5983
}
6084
if (start_line > end_line) {
61-
return { kind: "invalid", message: `start_line (${start_line}) must be <= end_line (${end_line})` };
85+
return {
86+
kind: "invalid",
87+
message: `start_line (${start_line}) must be <= end_line (${end_line})`,
88+
};
6289
}
6390
return { kind: "line_range", path, start_line, end_line, new_string };
6491
}
@@ -86,51 +113,48 @@ export function parseEditFileMode(args: Record<string, unknown>): EditFileModePa
86113
// slice, producing a confusing "old_string does not match" error even when the caller
87114
// meant plain substring mode (CL-4399). One explicit error beats a wrong guess.
88115
if (substring && lineRange) {
89-
return { kind: "invalid", message: MIXED_MODE_MESSAGE };
116+
return { kind: "invalid", message: mixedModeMessage(args) };
90117
}
91118

92119
if (lineRange) {
93120
return parseLineRangeFields(path, new_string, args);
94121
}
95122

96123
if (!substring) {
124+
const emptyOldString = typeof args.old_string === "string";
97125
return {
98126
kind: "invalid",
99-
message:
100-
'edit_file requires old_string (substring mode) or start_line and end_line (line-range mode)',
127+
message: emptyOldString
128+
? "edit_file: old_string is empty; provide the exact text to replace (substring mode), " +
129+
"or omit it and send start_line/end_line >= 1 (line-range mode). " +
130+
`Received ${describeReceived(args)}.`
131+
: "edit_file requires old_string (substring mode) or start_line and end_line (line-range mode); " +
132+
`received ${describeReceived(args)}`,
101133
};
102134
}
103135

104-
const old_string = String(args.old_string);
105-
if (old_string.length === 0) {
106-
return { kind: "invalid", message: "old_string must not be empty" };
107-
}
108-
109136
return {
110137
kind: "substring",
111138
path,
112-
old_string,
139+
old_string: String(args.old_string),
113140
new_string,
114141
replace_all: Boolean(args.replace_all),
115142
};
116143
}
117144

118-
export type SplitFileLines = {
145+
export interface SplitFileLines {
119146
lines: string[];
120147
newline: "\n" | "\r\n";
121148
/** File ended with a newline before the edit. */
122149
trailingNewline: boolean;
123-
};
150+
}
124151

125152
export function splitFileLines(content: string): SplitFileLines {
126153
const newline: "\n" | "\r\n" = content.includes("\r\n") ? "\r\n" : "\n";
127154
const trailingNewline =
128155
content.length > 0 && (newline === "\r\n" ? content.endsWith("\r\n") : content.endsWith("\n"));
129156

130-
let lines =
131-
newline === "\r\n"
132-
? content.split("\r\n")
133-
: content.split("\n");
157+
let lines = newline === "\r\n" ? content.split("\r\n") : content.split("\n");
134158

135159
if (trailingNewline && lines.length > 0 && lines[lines.length - 1] === "") {
136160
lines = lines.slice(0, -1);
@@ -149,7 +173,11 @@ function splitNewStringLines(newString: string): string[] {
149173
return parts;
150174
}
151175

152-
export function joinFileLines(lines: string[], newline: "\n" | "\r\n", trailingNewline: boolean): string {
176+
export function joinFileLines(
177+
lines: string[],
178+
newline: "\n" | "\r\n",
179+
trailingNewline: boolean,
180+
): string {
153181
if (lines.length === 0) {
154182
return trailingNewline ? newline : "";
155183
}
@@ -286,4 +314,4 @@ export function advertiseEditFileLineRange(definition: ToolDefinition): ToolDefi
286314
required: ["path", "new_string"],
287315
},
288316
};
289-
}
317+
}

0 commit comments

Comments
 (0)