stabilize hashline routing and anchors

This commit is contained in:
Shoubhit Dash
2026-02-27 09:45:06 +05:30
parent b2c82cb897
commit aec95c4d10
9 changed files with 228 additions and 73 deletions

View File

@@ -251,7 +251,12 @@ async function executeLegacy(params: LegacyEditParams, ctx: Tool.Context) {
}
}
async function executeHashline(params: HashlineEditParams, ctx: Tool.Context, autocorrect: boolean) {
async function executeHashline(
params: HashlineEditParams,
ctx: Tool.Context,
autocorrect: boolean,
aggressiveAutocorrect: boolean,
) {
const sourcePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
const targetPath = params.rename
? path.isAbsolute(params.rename)
@@ -345,6 +350,7 @@ async function executeHashline(params: HashlineEditParams, ctx: Tool.Context, au
trailing: parsed.trailing,
edits: params.edits,
autocorrect,
aggressiveAutocorrect,
})
const output = serializeHashlineContent({
lines: next.lines,
@@ -484,6 +490,7 @@ export const EditTool = Tool.define("edit", {
hashlineParams,
ctx,
config.experimental?.hashline_autocorrect !== false || Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
)
},
})

View File

@@ -18,6 +18,7 @@ Hashline schema (default behavior):
- Use strict anchor references from `Read` output: `LINE#ID`.
- Hashline mode can be turned off with `experimental.hashline_edit: false`.
- Autocorrect cleanup is on by default and can be turned off with `experimental.hashline_autocorrect: false`.
- Default autocorrect only strips copied `LINE#ID:`/`>>>` prefixes; set `OPENCODE_HL_AUTOCORRECT=1` to opt into heavier cleanup heuristics.
- When `Read` returns `LINE#ID:<content>`, prefer hashline operations.
- Operations:
- `set_line { line, text }`
@@ -28,4 +29,5 @@ Hashline schema (default behavior):
- `append { text }`
- `prepend { text }`
- `replace { old_text, new_text, all? }`
- In hashline mode, provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and must be retried with updated references.
- In hashline mode, provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and should be retried with the returned `retry with` anchors.
- Fallback guidance: GPT-family models can use `apply_patch` as fallback; non-GPT models should fallback to legacy `oldString/newString` payloads.

View File

@@ -9,6 +9,8 @@ import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectory } from "./external-directory"
import { Config } from "../config/config"
import { hashlineRef } from "./hashline"
const MAX_LINE_LENGTH = 2000
@@ -116,6 +118,7 @@ export const GrepTool = Tool.define("grep", {
}
const totalMatches = matches.length
const useHashline = (await Config.get()).experimental?.hashline_edit !== false
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
let currentFile = ""
@@ -129,7 +132,11 @@ export const GrepTool = Tool.define("grep", {
}
const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
if (useHashline) {
outputLines.push(` ${hashlineRef(match.lineNum, match.lineText)}:${truncatedLineText}`)
} else {
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
}
}
if (truncated) {

View File

@@ -2,7 +2,8 @@
- Searches file contents using regular expressions
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
- Returns file paths and line numbers with at least one match sorted by modification time
- Returns file paths with matching lines sorted by modification time
- Output format follows edit mode: `Line N:` when hashline mode is disabled, `N#ID:<content>` when hashline mode is enabled
- Use this tool when you need to find files containing specific patterns
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead

View File

@@ -8,6 +8,7 @@ export const HASHLINE_ALPHABET = "ZPMQVRWSNKTXJBYH"
const HASHLINE_ID_LENGTH = 2
const HASHLINE_ID_REGEX = new RegExp(`^[${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}}$`)
const HASHLINE_REF_REGEX = new RegExp(`(\\d+)#([${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}})(?=$|\\s|:)`)
const LOW_SIGNAL_CONTENT_RE = /^[^a-zA-Z0-9]+$/
type TextValue = string | string[]
@@ -75,12 +76,18 @@ export const HashlineEdit = z.discriminatedUnion("type", [
export type HashlineEdit = z.infer<typeof HashlineEdit>
function isLowSignalContent(normalized: string) {
if (normalized.length === 0) return true
if (normalized.length <= 2) return true
return LOW_SIGNAL_CONTENT_RE.test(normalized)
}
export function hashlineID(lineNumber: number, line: string): string {
let normalized = line
if (normalized.endsWith("\r")) normalized = normalized.slice(0, -1)
normalized = normalized.replace(/\s+/g, "")
void lineNumber
const hash = Bun.hash.xxHash32(normalized) & 0xff
const seed = isLowSignalContent(normalized) ? `${normalized}:${lineNumber}` : normalized
const hash = Bun.hash.xxHash32(seed) & 0xff
const high = (hash >>> 4) & 0x0f
const low = hash & 0x0f
return `${HASHLINE_ALPHABET[high]}${HASHLINE_ALPHABET[low]}`
@@ -123,29 +130,29 @@ function toLines(text: TextValue) {
}
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[ZPMQVRWSNKTXJBYH]{2}:/
const DIFF_PLUS_RE = /^[+-](?![+-])/
const WRAPPER_PREFIX_RE = /^\s*(?:>>>|>>)\s?/
function stripByMajority(lines: string[], test: (line: string) => boolean, rewrite: (line: string) => string) {
const nonEmpty = lines.filter((line) => line.length > 0)
if (nonEmpty.length === 0) return lines
const matches = nonEmpty.filter(test).length
if (matches === 0 || matches < nonEmpty.length * 0.5) return lines
return lines.map(rewrite)
}
function stripNewLinePrefixes(lines: string[]) {
let hashPrefixCount = 0
let diffPlusCount = 0
let nonEmpty = 0
for (const line of lines) {
if (line.length === 0) continue
nonEmpty++
if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount++
if (DIFF_PLUS_RE.test(line)) diffPlusCount++
}
if (nonEmpty === 0) return lines
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5
if (!stripHash && !stripPlus) return lines
return lines.map((line) => {
if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "")
if (stripPlus) return line.replace(DIFF_PLUS_RE, "")
return line
})
const stripped = stripByMajority(
lines,
(line) => HASHLINE_PREFIX_RE.test(line),
(line) => line.replace(HASHLINE_PREFIX_RE, ""),
)
return stripByMajority(
stripped,
(line) => WRAPPER_PREFIX_RE.test(line),
(line) => line.replace(WRAPPER_PREFIX_RE, ""),
)
}
function equalsIgnoringWhitespace(a: string, b: string) {
@@ -306,35 +313,51 @@ function mismatchContext(lines: string[], line: number) {
.join("\n")
}
function mismatchSummary(lines: string[], mismatch: { expected: string; line: number }) {
if (mismatch.line < 1 || mismatch.line > lines.length) {
return `- expected ${mismatch.expected} -> line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`
}
return `- expected ${mismatch.expected} -> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`
}
function throwMismatch(lines: string[], mismatches: Array<{ expected: string; line: number }>) {
const seen = new Set<string>()
const unique = mismatches.filter((m) => {
const key = `${m.expected}:${m.line}`
const unique = mismatches.filter((mismatch) => {
const key = `${mismatch.expected}:${mismatch.line}`
if (seen.has(key)) return false
seen.add(key)
return true
})
const body = unique
.map((m) => {
if (m.line < 1 || m.line > lines.length) {
const preview = unique.slice(0, 2).map((mismatch) => mismatchSummary(lines, mismatch))
const hidden = unique.length - preview.length
const count = unique.length
const linesOut = [
`Hashline edit rejected: ${count} anchor mismatch${count === 1 ? "" : "es"}. Re-read the file and retry with the updated anchors below.`,
...preview,
...(hidden > 0 ? [`- ... and ${hidden} more mismatches`] : []),
]
if (Bun.env.OPENCODE_HL_MISMATCH_DEBUG === "1") {
const body = unique
.map((mismatch) => {
if (mismatch.line < 1 || mismatch.line > lines.length) {
return [
`>>> expected ${mismatch.expected}`,
`>>> current line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`,
].join("\n")
}
return [
`>>> expected ${m.expected}`,
`>>> current line ${m.line} is out of range (1-${Math.max(lines.length, 1)})`,
`>>> expected ${mismatch.expected}`,
mismatchContext(lines, mismatch.line),
`>>> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`,
].join("\n")
}
})
.join("\n\n")
linesOut.push("", body)
}
const current = hashlineRef(m.line, lines[m.line - 1])
return [`>>> expected ${m.expected}`, mismatchContext(lines, m.line), `>>> retry with ${current}`].join("\n")
})
.join("\n\n")
throw new Error(
[
"Hashline edit rejected: file changed since last read. Re-read the file and retry with updated LINE#ID anchors.",
body,
].join("\n\n"),
)
throw new Error(linesOut.join("\n"))
}
function validateAnchors(lines: string[], refs: Array<{ raw: string; line: number; id: string }>) {
@@ -412,6 +435,7 @@ export function applyHashlineEdits(input: {
trailing: boolean
edits: HashlineEdit[]
autocorrect?: boolean
aggressiveAutocorrect?: boolean
}) {
const lines = [...input.lines]
const originalLines = [...input.lines]
@@ -420,6 +444,7 @@ export function applyHashlineEdits(input: {
const replaceOps: Array<Extract<HashlineEdit, { type: "replace" }>> = []
const ops: Splice[] = []
const autocorrect = input.autocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1"
const aggressiveAutocorrect = input.aggressiveAutocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1"
const parseText = (text: TextValue) => {
const next = toLines(text)
if (!autocorrect) return next
@@ -572,7 +597,7 @@ export function applyHashlineEdits(input: {
}
let text = op.text
if (autocorrect) {
if (autocorrect && aggressiveAutocorrect) {
if (op.kind === "set_line" || op.kind === "replace_lines") {
const start = op.startLine ?? op.start + 1
const end = op.endLine ?? start + op.del - 1

View File

@@ -137,6 +137,9 @@ export namespace ToolRegistry {
) {
const config = await Config.get()
const tools = await all()
const hashline = config.experimental?.hashline_edit !== false
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
const result = await Promise.all(
tools
.filter((t) => {
@@ -145,14 +148,12 @@ export namespace ToolRegistry {
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}
if (config.experimental?.hashline_edit !== false) {
if (t.id === "apply_patch") return false
if (hashline) {
if (t.id === "apply_patch") return usePatch
return true
}
// use apply tool in same format as codex
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
if (t.id === "apply_patch") return usePatch
if (t.id === "edit" || t.id === "write") return !usePatch

View File

@@ -37,6 +37,62 @@ describe("tool.grep", () => {
})
})
test("hashline disabled keeps Line N format", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await GrepTool.init()
const result = await grep.execute(
{
pattern: "alpha",
path: tmp.path,
},
ctx,
)
expect(result.output).toContain("Line 1: alpha")
},
})
})
test("hashline enabled emits N#ID anchor format", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await GrepTool.init()
const result = await grep.execute(
{
pattern: "alpha",
path: tmp.path,
},
ctx,
)
expect(result.output).toMatch(/\b1#[ZPMQVRWSNKTXJBYH]{2}:alpha\b/)
},
})
})
test("no matches returns correct output", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -7,6 +7,15 @@ function swapID(ref: string) {
return `${line}#${next}`
}
function errorMessage(run: () => void) {
try {
run()
return ""
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
describe("tool.hashline", () => {
test("hash computation is stable and 2-char alphabet encoded", () => {
const a = hashlineID(1, " const x = 1")
@@ -17,6 +26,15 @@ describe("tool.hashline", () => {
expect(a).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/)
})
test("low-signal lines mix line index into hash id", () => {
const a = hashlineID(1, "")
const b = hashlineID(2, "")
const c = hashlineID(1, "{}")
const d = hashlineID(2, "{}")
expect(a).not.toBe(b)
expect(c).not.toBe(d)
})
test("autocorrect strips copied hashline prefixes when enabled", () => {
const old = Bun.env.OPENCODE_HL_AUTOCORRECT
Bun.env.OPENCODE_HL_AUTOCORRECT = "1"
@@ -39,6 +57,23 @@ describe("tool.hashline", () => {
}
})
test("default autocorrect does not rewrite non-prefix content", () => {
const result = applyHashlineEdits({
lines: ["a"],
trailing: false,
edits: [
{
type: "set_line",
line: hashlineRef(1, "a"),
text: "+a",
},
],
autocorrect: true,
aggressiveAutocorrect: false,
})
expect(result.lines).toEqual(["+a"])
})
test("parses strict LINE#ID references with tolerant extraction", () => {
const ref = parseHashlineRef(">>> 12#ZP:const value = 1", "line")
expect(ref.line).toBe(12)
@@ -48,11 +83,11 @@ describe("tool.hashline", () => {
expect(() => parseHashlineRef("12#ab", "line")).toThrow("LINE#ID")
})
test("aggregates mismatch errors with >>> context and retry refs", () => {
test("reports compact mismatch errors with retry anchors", () => {
const lines = ["alpha", "beta", "gamma"]
const wrong = swapID(hashlineRef(2, lines[1]))
expect(() =>
const message = errorMessage(() =>
applyHashlineEdits({
lines,
trailing: false,
@@ -64,21 +99,12 @@ describe("tool.hashline", () => {
},
],
}),
).toThrow("changed since last read")
)
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "set_line",
line: wrong,
text: "BETA",
},
],
}),
).toThrow(">>> retry with")
expect(message).toContain("anchor mismatch")
expect(message).toContain("retry with")
expect(message).not.toContain(">>>")
expect(message.length).toBeLessThan(260)
})
test("applies batched line edits bottom-up for stable results", () => {

View File

@@ -4,16 +4,46 @@ import { Instance } from "../../src/project/instance"
import { ToolRegistry } from "../../src/tool/registry"
describe("tool.registry hashline routing", () => {
test.each([
{ providerID: "openai", modelID: "gpt-5" },
{ providerID: "anthropic", modelID: "claude-3-7-sonnet" },
])("disables apply_patch and enables edit by default (%o)", async (model) => {
await using tmp = await tmpdir()
test("hashline mode keeps edit and apply_patch for GPT models", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools(model)
const tools = await ToolRegistry.tools({
providerID: "openai",
modelID: "gpt-5",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("edit")
expect(ids).toContain("write")
expect(ids).toContain("apply_patch")
},
})
})
test("hashline mode keeps edit and removes apply_patch for non-GPT models", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: "anthropic",
modelID: "claude-3-7-sonnet",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("edit")
expect(ids).toContain("write")