Skip to content

Commit 2f2561b

Browse files
committed
edit_file: whitespace-tolerant fallback so small-model edits land (root fix)
The whole V3/Lens/ASA stack runs only AFTER a successful edit (improveContentWithV3 fires post-edit), but small models can't reproduce old_str byte-for-byte — indentation width, tabs vs spaces, trailing spaces drift every time — so edit_file fails, the model loops on re-reads/re-runs, and the smart machinery is never reached (observed: 10 reads / 9 run_commands / 1 failed edit / 0 successful edits in one session). GH #39 names this 'literal-match brittleness on whitespace mismatches'. findFuzzyLineMatch runs ONLY when exact + quote-normalized + entity- decoded matching all miss: it compares old_str to the file line-by-line with each line whitespace-stripped and accepts ONLY a unique match, returning the file's exact span (original indentation preserved) so the existing uniqueness/replace logic is unchanged. Ambiguous or content- different old_str still fails (verified) — it can't edit a guessed location. Exact-matching models never reach this path, so it can't regress them. This makes edits land → the downstream stack becomes reachable. Kept the first-miss ast_edit steer as the backstop for the residual cases fuzzy can't safely rescue.
1 parent 001b9af commit 2f2561b

2 files changed

Lines changed: 97 additions & 8 deletions

File tree

proxy/agent.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -895,14 +895,20 @@ func runAgentLoop(ctx *AgentContext, userMessage string) error {
895895
mp := extractFailurePath(parsed.Name, parsed.Args)
896896
editMissByPath[mp]++
897897
ext := strings.ToLower(filepath.Ext(mp))
898-
if editMissByPath[mp] >= 2 && (ext == ".py" || ext == ".html" || ext == ".htm") {
899-
pendingRepeatCorrective = "edit_file has failed to match old_str " +
900-
"on " + mp + " twice — stop re-reading and stop retrying " +
901-
"edit_file. Use ast_edit instead: {\"path\":\"" + mp + "\"," +
902-
"\"selector\":\"function:NAME\" (or class:NAME, or <tag> for " +
903-
"HTML), \"content\":\"<the full replacement node>\"}. ast_edit " +
904-
"needs no old_str, so it can't miss on whitespace."
905-
log.Printf("[agent] edit_file double-miss on %q — forcing ast_edit steer", mp)
898+
// Force the ast_edit steer on the FIRST miss for structured
899+
// files — small models bail to run_command after a single
900+
// edit_file miss rather than retrying, so waiting for a
901+
// second miss never fires (observed: 1 edit_file all session,
902+
// then 9 run_command re-runs).
903+
if editMissByPath[mp] >= 1 && (ext == ".py" || ext == ".html" || ext == ".htm") {
904+
pendingRepeatCorrective = "edit_file's old_str did not match " +
905+
mp + " (small drift in whitespace/quotes is enough to miss). " +
906+
"Do NOT re-read or run the file — switch to ast_edit, which " +
907+
"needs no old_str: {\"type\":\"tool_call\",\"name\":\"ast_edit\"," +
908+
"\"args\":{\"path\":\"" + mp + "\",\"selector\":\"function:NAME\" " +
909+
"(or class:NAME, or <tag> for HTML),\"content\":\"<the full " +
910+
"replacement function/class/element>\"}}."
911+
log.Printf("[agent] edit_file miss on %q — forcing ast_edit steer", mp)
906912
}
907913
}
908914

proxy/tools.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,23 @@ func editFileTool() *ToolDef {
963963
}
964964
}
965965
}
966+
if actualOldStr == "" {
967+
// Last resort before failing: whitespace-tolerant line match.
968+
// Small models can't reproduce a code block byte-for-byte —
969+
// indentation width, tabs-vs-spaces, and trailing spaces drift
970+
// constantly — so exact old_str matching is the #1 reason
971+
// edit_file fails for them, which strands the whole V3/lens
972+
// stack (it only engages after a successful edit). This
973+
// matches old_str against the file ignoring per-line leading/
974+
// trailing whitespace, and ONLY accepts a UNIQUE match — an
975+
// ambiguous or content-different old_str still fails, so it
976+
// can never silently edit the wrong place. Exact-matching
977+
// (frontier) models never reach this path. See GH #39.
978+
if fuzzy, ok := findFuzzyLineMatch(content, input.OldStr); ok {
979+
log.Printf("[edit_file] exact old_str missed on %s; unique whitespace-tolerant match found — proceeding (small-model indentation drift)", input.Path)
980+
actualOldStr = fuzzy
981+
}
982+
}
966983
if actualOldStr == "" {
967984
// Mismatch persists — return targeted error.
968985
hasEntities := strings.Contains(input.OldStr, "&lt;") ||
@@ -1455,6 +1472,72 @@ func improveContentWithV3(path, content string, ctx *AgentContext) (string, V3Ed
14551472

14561473
// findActualString searches for oldStr in content, handling quote normalization.
14571474
// Returns the actual string found in content (may differ in quote style).
1475+
// findFuzzyLineMatch rescues an old_str whose only error is per-line
1476+
// whitespace drift (indentation width, tabs vs spaces, trailing spaces) —
1477+
// the dominant edit_file failure mode for small models. It compares old_str
1478+
// against the file line-by-line with each line whitespace-stripped, and
1479+
// returns the EXACT span from the file (original indentation preserved) so
1480+
// the caller's existing uniqueness-count + replace logic works unchanged.
1481+
//
1482+
// Safety: it requires exactly ONE matching window. Zero or multiple matches
1483+
// return ("", false) so the caller fails cleanly rather than editing a
1484+
// guessed location. It also refuses to match when old_str has no
1485+
// non-whitespace content (would match any blank run). Content differences
1486+
// beyond whitespace (renamed tokens, changed quotes) do NOT match — those
1487+
// are semantic and must fail.
1488+
func findFuzzyLineMatch(content, oldStr string) (string, bool) {
1489+
fileLines := strings.Split(content, "\n")
1490+
oldLines := strings.Split(oldStr, "\n")
1491+
// Drop a single trailing empty line from a trailing newline in old_str.
1492+
if len(oldLines) > 1 && strings.TrimSpace(oldLines[len(oldLines)-1]) == "" {
1493+
oldLines = oldLines[:len(oldLines)-1]
1494+
}
1495+
if len(oldLines) == 0 {
1496+
return "", false
1497+
}
1498+
strip := func(ls []string) []string {
1499+
out := make([]string, len(ls))
1500+
nonEmpty := false
1501+
for i, l := range ls {
1502+
out[i] = strings.TrimSpace(l)
1503+
if out[i] != "" {
1504+
nonEmpty = true
1505+
}
1506+
}
1507+
if !nonEmpty {
1508+
return nil // all-whitespace target: refuse
1509+
}
1510+
return out
1511+
}
1512+
want := strip(oldLines)
1513+
if want == nil {
1514+
return "", false
1515+
}
1516+
n := len(want)
1517+
matchStart := -1
1518+
matches := 0
1519+
for i := 0; i+n <= len(fileLines); i++ {
1520+
ok := true
1521+
for j := 0; j < n; j++ {
1522+
if strings.TrimSpace(fileLines[i+j]) != want[j] {
1523+
ok = false
1524+
break
1525+
}
1526+
}
1527+
if ok {
1528+
matches++
1529+
matchStart = i
1530+
if matches > 1 {
1531+
return "", false // ambiguous — fail safe
1532+
}
1533+
}
1534+
}
1535+
if matches != 1 {
1536+
return "", false
1537+
}
1538+
return strings.Join(fileLines[matchStart:matchStart+n], "\n"), true
1539+
}
1540+
14581541
func findActualString(content, oldStr string) string {
14591542
// Direct match first
14601543
if strings.Contains(content, oldStr) {

0 commit comments

Comments
 (0)