diff --git a/packages/coding-agent/src/hashline/anchors.ts b/packages/coding-agent/src/hashline/anchors.ts index 5805220a4..a0977e8e1 100644 --- a/packages/coding-agent/src/hashline/anchors.ts +++ b/packages/coding-agent/src/hashline/anchors.ts @@ -63,9 +63,9 @@ export class HashlineMismatchError extends Error { } private static rejectionHeader(mismatches: HashMismatch[]): string[] { - const noun = mismatches.length > 1 ? "lines have" : "line has"; + const noun = mismatches.length > 1 ? "anchors do" : "anchor does"; return [ - `Edit rejected: ${mismatches.length} ${noun} changed since the last read (marked *).`, + `Edit rejected: ${mismatches.length} ${noun} not match the current file (marked *).`, "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.", ]; } diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index c177c90e1..d3fc02c20 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -4,7 +4,7 @@ A patch contains one or more file sections. The first non-blank line of every ed Operations reference lines in the file by their line number and hash, called "Anchors", e.g. `5th`, `123ab`. You MUST copy them verbatim from the latest output for the file you're editing. -Purely textual format. The tool has NO awareness of language, indentation, brackets, fences, or table widths. Emit valid syntax in replacements/insertions. +Purely textual format. The tool has NO awareness of language, indentation, brackets, fences, or table widths. You MUST emit valid syntax in replacements/insertions. @@ PATH header: subsequent ops apply to PATH @@ -21,155 +21,130 @@ Op lines carry no content — payload goes on the next line. WRONG: + 5pg| some code RIGHT: + 5pg {{hsep}} some code + +A single `+`/`<`/`=` op accepts MANY `{{hsep}}` payload lines. To insert N consecutive lines, write ONE op followed by N payload lines — NEVER N ops with one payload each. + +WRONG (one op per inserted line, with fabricated anchors): + + 5pg + {{hsep}}first new line + + 6xx ← FABRICATED + {{hsep}}second new line + +RIGHT (one op, many payload lines): + + 5pg + {{hsep}}first new line + {{hsep}}second new line -- Every line of inserted/replacement content MUST be emitted as a payload line starting with `{{hsep}}`. -- `{{hsep}}` is syntax, not content. The inserted text begins after the first `{{hsep}}`; use a bare `{{hsep}}` to insert a blank line. -- Payload is verbatim — don't escape unicode (write `—`, not `\u2014`). -- `< A` inserts before line A; `+ A` inserts after line A. `< BOF` / `+ BOF` both prepend; `< EOF` / `+ EOF` both append. -- `= A..B` replaces the inclusive range with the following payload lines. `= A..B` with no payload blanks the range to a single empty line. -- `- A..B` deletes the inclusive range; `A..A` for one line. -- **Payloads are only what is NEW. Never emit source that already exists in the file.** The patch describes a *delta*, not the file's new state. Unchanged lines stay in place automatically — they are not "consumed" by adjacent ops and do not need to be "preserved", "restored", or "re-emitted": - - `= A..B` replaces ONLY the lines inside the range. Lines outside `A..B` stay as-is. Do not include any of them in the payload. - - `+ A` / `< A` add NEW lines at the anchor. Line A itself stays in place. Do not put its content (or content of lines adjacent to A) into the payload. - - If any payload line matches the current file content at or adjacent to the op's target, you are about to duplicate that line. Drop it from the payload, or widen the range so the op actually consumes it. -- **Choose a self-contained syntactic unit first.** If the change touches part of a multiline call, destructuring assignment, control-flow header, wrapper, or other construct, widen the range to include the whole construct before optimizing for size. -- Only after the range is self-contained, pick the smallest op for the change: pure addition → `+`/`<`; pure deletion → `-`; `= A..B` ONLY when content inside `A..B` is actually being modified or removed. +- Every payload line MUST start with `{{hsep}}`. +- Payload is verbatim — NEVER escape unicode. +- **Payload is only what's NEW relative to your range:** + - `=` replaces inside; NEVER include lines outside. + - `+`/`<` adds at the anchor; NEVER repeat line A or neighbors. + - Payload matching nearby content duplicates — drop it or widen. +- **Pick a self-contained unit first.** Touching a multiline construct? Widen to the whole thing. +- Then smallest op: add → `+`/`<`; delete → `-`; `=` ONLY when modifying inside. -When your edit involves brace boundaries (`{` / `}`), prefer these shapes: -- **Whole block replace/delete**: pick the range so it spans both halves of the brace pair — start on the line that ends with `{`, end on the matching `}`. For pure removal use `-` with empty payload; for replacement, the payload's first line ends with `{` and last line is the matching `}`. -- **Signature-only edit**: if you are only changing the line that ends with `{` (function signature, control statement, etc.), use a one-line `=` on that opener; the body and matching `}` are untouched and stay outside the range. -- **Insert inside a block**: anchor on the opener (`+ ANCHOR` after the `{` line) or just above the closer (`+ ANCHOR` after the last interior line); emit only the new interior lines. Do not include the surrounding `{` or `}` in the payload — they're already there. -- **Range ending on `}`**: only end on `}` when that `}` is itself part of what you're changing. The line at B+1 should be blank, an opener (next block), or a signature — not another `}`. Otherwise extend B past the closer or stop one line earlier. +When braces bound your edit, you SHOULD prefer these shapes: +- **Whole block**: range spans `{` through matching `}`. +- **Signature only**: one-line `=` on the opener; body untouched. +- **Insert inside**: anchor on `{` or last interior line; NEVER repeat the braces. +- **End on `}`**: only when that `}` is part of the change. Otherwise extend or stop earlier. -- **Do not replay the line past your range.** For `= A..B`, never end the payload with content that already exists at B+1. Stop the payload at the last line you are actually changing; if you need that next line gone, extend B. -- **Do not duplicate chunks inside one payload.** When emitting a long `=` payload, never paste the same multi-line block twice. If you catch yourself re-emitting an earlier run of lines, stop and rewrite the op. -- **Anchor only inside the visible region.** If the read output around your `=`/`-` end anchor is truncated (you cannot see the line at B+1), issue a fresh `read` before editing — anchoring blind drops or duplicates the boundary line. -- **Prefer the narrowest self-contained edit.** Once your range cleanly contains the construct you are changing, a `+`/`<` insert plus a small `-` delete is almost always clearer and safer than a single wide `= A..B` that re-emits unchanged context. -- **Anchors always reference the file as you last read it.** When stacking multiple `+`/`<`/`-`/`=` ops in one patch, NEVER mentally shift line numbers to account for prior ops in the same patch. Every op resolves against the original line numbering. +- **NEVER replay past your range.** Stop before B+1; extend B if it must go. +- **NEVER duplicate chunks inside one payload.** Caught re-emitting? Rewrite. +- **Anchor only inside the visible region.** B+1 truncated? Re-`read` first. +- **You SHOULD prefer the narrowest self-contained edit.** Small `+`/`-` beats wide `=`. +- **Anchors reference the file as last read.** NEVER shift for prior ops. +- **One `+`/`<` op per block, NOT per line.** N lines = ONE op, N payloads. Collapse adjacent ops. +- **NEVER fabricate anchor hashes.** Missing? Re-`read`. - + {{hline 1 "const DEF = \"guest\";"}} -{{hline 2 ""}} -{{hline 3 "export function label(name) {"}} -{{hline 4 "\tconst clean = name || DEF;"}} -{{hline 5 "\treturn clean.trim();"}} -{{hline 6 "}"}} - - - -{{hline 1 "const {"}} -{{hline 2 "\tevents,"}} -{{hline 3 "\tresponse,"}} -{{hline 4 "\trequestId,"}} -{{hline 5 "} = await getStreamResponse("}} -{{hline 6 "\trequest,"}} -{{hline 7 "\tsignal,"}} -{{hline 8 ");"}} -{{hline 9 "await notify(requestId);"}} +{{hline 2 "export function label(name) {"}} +{{hline 3 "\treturn ["}} +{{hline 4 "\t\tname?.trim() || DEF,"}} +{{hline 5 "\t\t\" • \","}} +{{hline 6 "\t].join(\"\");"}} +{{hline 7 "}"}} -# Replace one line (preserve the leading tab from the original) -@@ a.ts -= {{hrefr 5}}..{{hrefr 5}} -{{hsep}} return clean.trim().toUpperCase(); +# Replace one line (the payload must re-emit the original indentation) +@@ mod.ts += {{hrefr 4}}..{{hrefr 4}} +{{hsep}} name?.trim().toUpperCase() || DEF, -# Replace a contiguous range with multiple lines -@@ a.ts -= {{hrefr 4}}..{{hrefr 5}} -{{hsep}} const clean = (name || DEF).trim(); -{{hsep}} return clean.length === 0 ? DEF : clean.toUpperCase(); +# Replace a full multiline statement (widen to a self-contained boundary) +@@ mod.ts += {{hrefr 3}}..{{hrefr 6}} +{{hsep}} return [ +{{hsep}} name?.trim() || DEF, +{{hsep}} "·", +{{hsep}} " • ", +{{hsep}} ].join(""); -# Replace a full multiline destructuring/call statement -@@ b.ts -= {{hrefr 1}}..{{hrefr 8}} -{{hsep}}const { -{{hsep}} events, -{{hsep}} response, -{{hsep}} requestId, -{{hsep}}} = await getStreamResponse( -{{hsep}} request, -{{hsep}} signal, -{{hsep}} onEvent, -{{hsep}}); - -# Insert BEFORE a line -@@ a.ts +# Insert AFTER/BEFORE a line +@@ mod.ts ++ {{hrefr 3}} +{{hsep}} "·", < {{hrefr 5}} -{{hsep}} const debug = false; +{{hsep}} "·", -# Insert AFTER a line -@@ a.ts -+ {{hrefr 4}} -{{hsep}} if (clean.length === 0) return DEF; - -# Append to end of file -@@ a.ts +# Append to file +@@ mod.ts + EOF {{hsep}}export const done = true; -# Delete a single line -@@ a.ts -- {{hrefr 2}}..{{hrefr 2}} +# Delete a line +@@ mod.ts +- {{hrefr 5}}..{{hrefr 5}} -# Blank a line in place (no payload required) -@@ a.ts -= {{hrefr 2}}..{{hrefr 2}} +# Blank a line (replace with LF) +@@ mod.ts += {{hrefr 5}}..{{hrefr 5}} -# WRONG — replaces 5 lines just to add one. Use `+` at the boundary instead. -@@ a.ts -= {{hrefr 1}}..{{hrefr 5}} +# WRONG — replaces 3 lines just to add one. +@@ mod.ts += {{hrefr 1}}..{{hrefr 3}} {{hsep}}const DEF = "guest"; {{hsep}}const DEBUG = false; -{{hsep}} {{hsep}}export function label(name) { -{{hsep}} const clean = name || DEF; -{{hsep}} return clean.trim(); - +{{hsep}} return [ # RIGHT — same effect, one-line insert -@@ a.ts +@@ mod.ts + {{hrefr 1}} {{hsep}}const DEBUG = false; -# WRONG — continuation-fragment payload from the middle of a larger statement. -@@ b.ts -= {{hrefr 5}}..{{hrefr 7}} -{{hsep}}} = await getStreamResponse( -{{hsep}} request, -{{hsep}} signal, -{{hsep}} onEvent, - -# RIGHT — widen to the full statement so the payload starts at a self-contained boundary. -@@ b.ts -= {{hrefr 1}}..{{hrefr 8}} -{{hsep}}const { -{{hsep}} events, -{{hsep}} response, -{{hsep}} requestId, -{{hsep}}} = await getStreamResponse( -{{hsep}} request, -{{hsep}} signal, -{{hsep}} onEvent, -{{hsep}}); - -If your replacement payload would render with even one unchanged line in the diff, or if the first or last payload line is only a continuation fragment from a larger construct (`} =`, `);`, `,`, `.method(`), you have the wrong op or range. Stop and widen to a self-contained boundary before minimizing the edit. +# WRONG — replace from the middle of a larger statement (error-prone) +@@ mod.ts += {{hrefr 4}}..{{hrefr 5}} +{{hsep}} name?.trim() || DEF, +{{hsep}} "·", +{{hsep}} " • ", +# RIGHT — widen to the full statement +@@ mod.ts += {{hrefr 3}}..{{hrefr 6}} +{{hsep}} return [ +{{hsep}} name?.trim() || DEF, +{{hsep}} "·", +{{hsep}} " • ", +{{hsep}} ].join(""); -- Always copy anchors exactly from tool output, but NEVER include line content after the `{{hsep}}` separator in the op line. -- Every inserted/replacement content line MUST start with `{{hsep}}`; raw content lines are invalid. -- Do not write unified diff syntax (`@@ -X,Y +X,Y @@`, `-OLD`, `+NEW`). The header is `@@ PATH`; line ops are `<`/`+`/`-`/`=`. -- `= A..B` deletes the range; payload is what's written. If a payload edge line already exists immediately outside `A..B`, widen the range to cover it — otherwise it duplicates. -- Multiple ops in one patch are cheap. Prefer two narrow ops over one wide `=`. - - Before choosing a `= A..B` range, mentally delete lines A through B. If that would split an unclosed bracket, paren, brace, or string/template from a line above A, or orphan a closing delimiter that belongs to an opener inside the range, you are bisecting a syntactic construct. Widen the range to a self-contained boundary, or use `+`/`-` instead. - - `= A..B` removes the range as a unit; the lines immediately outside it remain. If those outside lines form a wrapper (`try {`, `catch`, `if`, `else`, loop delimiters) you do not intend to delete, your payload is inserted inside that wrapper. Make sure the payload remains valid and preserves required behavior like error handling. If you need to change the wrapper itself, include it in the range and reproduce it. +- Copy anchors verbatim (line number + 2-char hash); NEVER include the `|TEXT` body. +- Every payload line MUST start with `{{hsep}}`; raw content is invalid. +- NEVER write unified diff syntax. Header is `@@ PATH`; ops are `<`/`+`/`-`/`=`. +- `= A..B` deletes the range; payload is what's written. Edge line matches just outside? Widen, or it duplicates. +- Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `=`. + - Before `= A..B`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct. diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index a0a38033c..a0f41b32a 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -445,7 +445,7 @@ describe("hashline executor", () => { ].join("\n"); await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow( - /changed since the last read/, + /anchor(s)? do(es)? not match the current file/, ); expect(await Bun.file(aPath).text()).toBe("aaa\n"); expect(await Bun.file(bPath).text()).toBe("bbb\n"); diff --git a/packages/utils/src/prompt.ts b/packages/utils/src/prompt.ts index ee751174d..c2845d26b 100644 --- a/packages/utils/src/prompt.ts +++ b/packages/utils/src/prompt.ts @@ -84,15 +84,8 @@ function replaceCommonAsciiSymbols(line: string): string { .replace(/>=/g, "≥"); } -function replaceCommonAsciiSymbolsOutsideHtmlComments( - line: string, - state: HtmlCommentState, -): string { - if ( - !state.inHtmlComment && - !line.includes(HTML_COMMENT_OPEN) && - !line.includes(HTML_COMMENT_CLOSE) - ) { +function replaceCommonAsciiSymbolsOutsideHtmlComments(line: string, state: HtmlCommentState): string { + if (!state.inHtmlComment && !line.includes(HTML_COMMENT_OPEN) && !line.includes(HTML_COMMENT_CLOSE)) { return replaceCommonAsciiSymbols(line); } diff --git a/scripts/session-stats/analyze.py b/scripts/session-stats/analyze.py index 8c60dd097..180678885 100644 --- a/scripts/session-stats/analyze.py +++ b/scripts/session-stats/analyze.py @@ -271,7 +271,8 @@ _RE_SUCCESS = re.compile( re.I, ) _RE_ANCHOR_STALE = re.compile( - r"(Edit rejected:.*line[s]? .* changed since the last read|" + r"(Edit rejected:.*anchor[s]? do(es)? not match the current file|" + r"Edit rejected:.*line[s]? .* changed since the last read|" r"line[s]? ha(s|ve) changed since last read)", re.I, )