You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Bug Model enters infinite loop due to hallucinated oldString in edit tool calls #21850
While using the edit tool to fix a simple syntax error in a Rust file, the model (gemma4-31b) entered a repetitive failure loop.
Steps to Reproduce:
The model calls read to get the content of a file.
The read tool returns the actual content (e.g., if guess << secret secret_number {).
The model attempts to call edit, but instead of using the string returned by read, it hallucinates a different string for oldString (e.g., if guess <<< secret secret secret_number {).
The edit tool correctly returns an error: "Could not find oldString in the file."
The model ignores this error and repeats the exact same incorrect edit call multiple times (10+ times) without updating the oldString to match the actual file content.
Expected Behavior:
The model should prioritize the output of the read tool and, upon receiving a "not found" error from the edit tool, it should re-examine the file content and correct the oldString parameter.
Actual Behavior:
The model ignores the tool's error message and persists in using a hallucinated string, resulting in an infinite loop of failed tool calls.
Environment:
I've been playing around with a replacement edit tool that doesn't need to generate the oldString at all. Instead it uses system calls to read the lines of the file into memory determinstically (so it doesn't need to generate the oldString) and uses it directly. It only has to generate the replacement string.
This tool is a little faster than the old edit tool since it doesn't do 2 generate steps (old and new), it only generates once (the new).
Put it in your opencode folder here, when you launch opencode it will pick up the edit tool and replace its internal default edit tool with this one:
~/.config/opencode/tools/edit.ts
Here's the tool:
import { tool } from "@opencode-ai/plugin"
import * as fs from "fs/promises"
// Replaces this file's built-in `edit` tool (same name = override).
//
// The built-in tool matches an exact `oldString` snippet and requires
// repeating unchanged surrounding lines in `newString`. Small/local models
// constantly get that snippet slightly wrong (whitespace, picking a snippet
// that isn't unique, typos when retranscribing context) and burn a retry
// every time. Addressing by line number instead - just startLine/endLine
// plus the new content - avoids both problems, since the model never has
// to transcribe lines it isn't changing.
// Builds a single-hunk unified diff (3 lines of context) for the edited
// range, in the format the opencode TUI expects for its diff view.
function unifiedDiff(
filePath: string,
oldLines: string[],
startLine: number,
endLine: number,
replacement: string[],
): string {
const CONTEXT = 3
const removedCount = Math.max(0, endLine - startLine + 1)
const contextBefore = Math.min(CONTEXT, startLine - 1)
const contextAfter = Math.min(CONTEXT, oldLines.length - endLine)
const oldStart = startLine - contextBefore
const oldCount = contextBefore + removedCount + contextAfter
const newStart = oldStart
const newCount = contextBefore + replacement.length + contextAfter
const hunkLines: string[] = []
for (let i = oldStart; i < startLine; i++) hunkLines.push(` ${oldLines[i - 1]}`)
for (let i = startLine; i <= endLine; i++) hunkLines.push(`-${oldLines[i - 1]}`)
for (const line of replacement) hunkLines.push(`+${line}`)
for (let i = endLine + 1; i <= endLine + contextAfter; i++) hunkLines.push(` ${oldLines[i - 1]}`)
return (
`--- ${filePath}\n` +
`+++ ${filePath}\n` +
`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n` +
hunkLines.join("\n") +
"\n"
)
}
export default tool({
description:
"Edit a file by line number. Use the read or grep tool first to find the correct " +
"line numbers - the read tool returns lines as '<line>: <content>'. " +
"mode='replace' (default) replaces lines startLine..endLine (1-indexed, inclusive) " +
"with newContent - omit endLine to replace just startLine. " +
"If newContent has more lines than the range being replaced, set endLine to cover " +
"every line you're touching - any extra newContent lines beyond that range are " +
"inserted, NOT used to overwrite the lines that follow. " +
"mode='insertBefore'/'insertAfter' insert newContent as new lines before/after " +
"startLine without removing anything - endLine is ignored for these modes. " +
"To delete lines, use mode='replace' with an empty newContent. " +
"IMPORTANT: newContent must contain ONLY the actual file content - do NOT include " +
"the '<line>: ' prefix shown by the read tool, and do NOT add a trailing newline " +
"or '\\n' at the end - the tool handles line breaks between lines automatically.",
args: {
filePath: tool.schema.string().describe("Absolute path to the file to edit"),
mode: tool.schema
.enum(["replace", "insertBefore", "insertAfter"])
.optional()
.describe(
"'replace' (default): replace startLine..endLine with newContent. " +
"'insertBefore'/'insertAfter': insert newContent as new lines before/after startLine, endLine ignored.",
),
startLine: tool.schema.number().int().min(1).describe("Line number to anchor the edit to (1-indexed)"),
endLine: tool.schema
.number()
.int()
.min(0)
.optional()
.describe("Last line to replace (1-indexed, inclusive). Only used for mode='replace'; defaults to startLine."),
newContent: tool.schema
.string()
.describe("Replacement text for the line range, without line-number prefixes and without a trailing newline."),
},
async execute(args, ctx) {
const { filePath, mode = "replace" } = args
// Strip a trailing newline - real, or the literal two-character "\n"
// the model sometimes writes instead.
let newContent = args.newContent.replace(/(\\n|\n)$/, "")
const original = await fs.readFile(filePath, "utf8").catch((err) => {
if (err.code === "ENOENT") {
throw new Error(`${filePath} does not exist - use the write tool to create new files`)
}
throw err
})
// Preserve CRLF line endings: work in LF internally, restore on write.
const usesCRLF = original.includes("\r\n")
const normalized = usesCRLF ? original.replace(/\r\n/g, "\n") : original
const lines = normalized.split("\n")
// split() on a trailing-newline file produces a trailing "" entry; drop it
// so line counts match what `read` shows, then restore it on write.
const hadTrailingNewline = lines.length > 0 && lines[lines.length - 1] === ""
if (hadTrailingNewline) lines.pop()
const total = lines.length
if (args.startLine < 1 || args.startLine > total + 1) {
throw new Error(`startLine ${args.startLine} out of range (file has ${total} lines)`)
}
// insertBefore/insertAfter are just replacements of an empty range
// positioned before/after the anchor line.
let startLine: number
let endLine: number
if (mode === "insertBefore") {
startLine = args.startLine
endLine = args.startLine - 1
} else if (mode === "insertAfter") {
startLine = args.startLine + 1
endLine = args.startLine
} else {
startLine = args.startLine
endLine = args.endLine ?? args.startLine
}
if (endLine < startLine - 1 || endLine > total) {
throw new Error(`endLine ${endLine} out of range (file has ${total} lines)`)
}
let replacement = newContent === "" ? [] : newContent.split("\n")
// Models sometimes echo back the "<line>: " prefix from the read tool
// instead of sending bare content. Strip it, but only if every line has
// it - otherwise we'd mangle real content that happens to start with digits.
const linePrefixPattern = /^\s*\d+(?:\t| ?: ?)/
if (replacement.length > 0 && replacement.every((l) => linePrefixPattern.test(l))) {
replacement = replacement.map((l) => l.replace(linePrefixPattern, ""))
}
// Lines that map 1:1 onto a line being replaced keep that line's
// original indentation - models get this whitespace wrong constantly
// even when the content itself is fine.
const replacedCount = endLine - startLine + 1
replacement = replacement.map((l, i) => {
if (i >= replacedCount || l === "") return l
const original = lines[startLine - 1 + i]
if (original === undefined) return l
const originalIndent = original.match(/^[ \t]*/)?.[0] ?? ""
const replacementIndent = l.match(/^[ \t]*/)?.[0] ?? ""
return originalIndent === replacementIndent ? l : originalIndent + l.slice(replacementIndent.length)
})
// Lines beyond the 1:1 mapping are newly inserted. If all of them lack
// leading whitespace, indentation was stripped wholesale - reapply the
// indentation of the line before the insertion point. Lines that already
// have whitespace opt out, so deliberate re-indents still work.
const insertedLines = replacement.slice(replacedCount)
if (insertedLines.length > 0 && insertedLines.every((l) => l === "" || !/^[ \t]/.test(l))) {
const anchorLine = replacedCount > 0 ? replacement[replacedCount - 1] : lines[startLine - 2]
const baseIndent = anchorLine?.match(/^[ \t]*/)?.[0] ?? ""
if (baseIndent) {
replacement = replacement.map((l, i) => (i < replacedCount || l === "" ? l : baseIndent + l))
}
}
const updated = [...lines.slice(0, startLine - 1), ...replacement, ...lines.slice(endLine)]
const eol = usesCRLF ? "\r\n" : "\n"
await fs.writeFile(filePath, updated.join(eol) + (hadTrailingNewline ? eol : ""), "utf8")
// Build a unified diff for the edited hunk so the TUI renders this like
// the built-in edit tool's red/green diff view.
const diff = unifiedDiff(filePath, lines, startLine, endLine, replacement)
ctx.metadata({
metadata: {
diff,
filediff: {
file: filePath,
patch: diff,
additions: replacement.length,
deletions: endLine - startLine + 1 > 0 ? endLine - startLine + 1 : 0,
},
},
})
// Return a few lines of context around the edit, with updated line
// numbers, so the model can verify the result and correctly target
// follow-up edits without re-reading the whole file.
const contextStart = Math.max(1, startLine - 2)
const contextEnd = Math.min(updated.length, startLine - 1 + replacement.length + 2)
const preview = updated
.slice(contextStart - 1, contextEnd)
.map((l, i) => `${contextStart + i}: ${l}`)
.join("\n")
return {
output:
`Replaced lines ${startLine}-${endLine} in ${filePath} (${updated.length} lines now).\n` +
`Updated content (lines ${contextStart}-${contextEnd}):\n${preview}`,
metadata: { diff },
}
},
})
Feel free to read the code, comments, have an llm code review it, etc. before you use it.
2 benefits:
It's more deterministic since it doesn't need to generate the oldString, it uses system calls
It uses less tokens because it doesn't need to generate the oldString
By using less tokens, it gets the job done faster.
This tool is not FOR smaller models, larger models will benefit as well because of the token use reduction.
But small models will benefit more from it because of the oldString hallucination issue.
Edit: If someone finds this as a good starting point for replacing the real edit tool, you have my permission. I just want things to be better and I love contributing.
Activity
github-actions commentedon Apr 10, 2026
This issue might be a duplicate of existing issues. Please check:
Please review these issues to see if your report is already covered, and consider adding your findings there if so.
BrutchsamaJeanLouis commentedon Apr 10, 2026
there is also issue 16531 that blocks gemma4 support
nateonguitar commentedon Jun 11, 2026
I've been playing around with a replacement edit tool that doesn't need to generate the oldString at all. Instead it uses system calls to read the lines of the file into memory determinstically (so it doesn't need to generate the oldString) and uses it directly. It only has to generate the replacement string.
This tool is a little faster than the old edit tool since it doesn't do 2 generate steps (old and new), it only generates once (the new).
Put it in your opencode folder here, when you launch opencode it will pick up the edit tool and replace its internal default edit tool with this one:
Here's the tool:
Feel free to read the code, comments, have an llm code review it, etc. before you use it.
2 benefits:
By using less tokens, it gets the job done faster.
This tool is not FOR smaller models, larger models will benefit as well because of the token use reduction.
But small models will benefit more from it because of the oldString hallucination issue.
Edit: If someone finds this as a good starting point for replacing the real edit tool, you have my permission. I just want things to be better and I love contributing.
github-actions commentedon Aug 12, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.