Skip to content

🎯 feat: Diagnose Every Failing Workspace Edit and Negotiate Tolerant Matching - #271

Open
danny-avila wants to merge 5 commits into
mainfrom
danny-avila/edit-matching
Open

danny-avila wants to merge 5 commits into
mainfrom
danny-avila/edit-matching

Conversation

@danny-avila

Copy link
Copy Markdown
Collaborator

Summary

Agents using a paired worker's edit_file fail often, and each failure costs them extra turns. Over 10 days on one deployment, 125 of 2,935 edit_file calls (4.3%) failed with EDIT_CONFLICT. Every one got the same message: Workspace edit must match exactly once. That message does not say whether the text was missing or repeated, which edit in the batch failed, or where the intended text is. 94 of the 125 failures were multi-edit batches (2–14 edits), where one bad edit rejects the whole call. The agent's usual recovery was a blind retry (35) or a full re-read (30).

This PR changes the worker's edit engine in two ways.

Every failing edit is diagnosed, in all modes, with no negotiation. Edits still apply in order and still commit atomically. When one fails, the worker keeps checking the rest and rejects the batch with a single EDIT_CONFLICT. Its message lists every failing edit by position:

  • Missing edits name the nearest candidate line. They also flag the usual causes: an elision (...) in oldText, copied line-number prefixes, a whitespace-only difference (with the line where the text does exist), or CRLF line endings.
  • Ambiguous edits give their match count and line numbers. As before, overlapping occurrences count as separate locations.

The message stays under 3,000 characters to fit the 4,096-character settlement bound, and it travels through the existing error path unchanged.

2 of 3 workspace edits did not apply, so nothing was written. Every other edit matched.
Edit 2: old_text matched 2 locations at lines 2, 3; include more surrounding lines so it matches exactly one.
Edit 3: old_text was not found; the closest line is line 9: "const total = price * quantity;".
Line numbers account for the earlier edits in this batch.

Two negotiated edit features use the existing editFileFeatures handshake. The worker advertises them, the Code API lists them in supportedWorkspaceEditFileFeatures, and it only dispatches requests that use them to workers that negotiated them:

  • tolerant_match: a request-level matching: 'tolerant'. When an exact match fails, it falls back in order to:

    • line-trimmed: ignores trailing whitespace and CRLF;
    • indentation-flexible: matches a uniformly shifted block, and moves newText to the file's own indentation;
    • whitespace-normalized: any whitespace run between tokens, and strips the leading and trailing whitespace the caller wrapped around both texts.

    A match must still be unique, and replacements keep the file's line endings.

  • replace_all: a batch edit's replaceAll: true replaces every non-overlapping match. It still fails when nothing matches.

A request that sets matching or any replaceAll gets matches: one { strategy, occurrences } per edit. A request that sets neither gets exactly the legacy result, so older callers and validators never see a new key.

The tolerant tiers follow LibreChat's existing skill-file matcher, but fix three problems found while porting it:

  • Tier order: whitespace-normalized now runs last, so an indentation-only difference is handled by the tier that re-indents newText. Before, it inserted newText as written.
  • CRLF: line-window matches keep CRLF on the last matched line and in the replacement.
  • Trailing newline: an oldText ending in a newline matches through the line terminator, instead of requiring an extra blank line.

Related to #267. LibreChat will opt into tolerant_match and replace_all in a separate PR, and render these diagnostics.

How it works

applyWorkspaceEdits (edit_file, preview_edit)
  applyTextEdits(text, edits, matching)          packages/code/src/edits.ts
    for each edit, against the text so far:
      exact                                        always
      line-trimmed -> indentation-flexible -> whitespace-normalized   when matching: 'tolerant'
      unique match, or every match with replaceAll -> apply
      otherwise record { index, reason }, keep going
    any failures -> WorkspaceEditMatchError -> WorkspaceToolError('EDIT_CONFLICT')

Negotiation and gating:

  • WORKSPACE_EDIT_FILE_FEATURES in protocol.ts is the single list the worker advertises, the Code API negotiates and capability validation accepts, as any unique subset.
  • supportsWorkspaceTool in bridge/store.ts refuses matching or replaceAll for a worker that did not negotiate the matching feature, the same way it gates expected_base_sha256.
  • Result validation requires matches exactly when the request opted in, with exact strategies only for non-tolerant requests and occurrences > 1 only for replaceAll edits.

Testing

  • packages/code: new edits.test.ts (17 cases, covering diagnostics, tolerant tiers, CRLF, re-indentation, replaceAll, overlap, the output bound and large-file performance); worker integration tests in workspace.test.ts through LocalWorkspaceTools; protocol tests for request, result and capability validation. npm test: 564 pass. The 9 failures (PTC watchdog and credential storage tests) fail identically on unmodified main in the same WSL environment.
  • service: bun test ./src/bridge: 196 pass, 0 fail, including new gates for tolerant edits, tolerant previews and replaceAll. tsc --noEmit reports the same 7 existing errors as main and no new ones.
  • service bun run build fails the same way on main locally (Node 18 loading the rollup config), so CI's build is the check there.

…Matching

A rejected edit batch now reports every failing edit by position: missing
edits name the nearest candidate line and flag elided, line-numbered,
whitespace-only or CRLF mismatches, and ambiguous edits give their match
count and line numbers. Two negotiated edit features add tolerant matching
(line-trimmed, indentation-flexible, whitespace-normalized) and replaceAll,
with per-edit match reporting only for requests that opt in.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-29T13:25:22.464281Z 4b677df Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 25adab506f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/code/src/edits.ts Outdated
Comment thread packages/code/src/edits.ts Outdated
Comment thread packages/code/src/edits.ts Outdated
Comment thread packages/code/src/protocol.ts
Comment thread packages/code/src/workspace.ts
Comment thread packages/code/src/edits.ts Outdated

@lia-by-librechat lia-by-librechat Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed and fixed all six open Codex findings at 8d1a44ecfb24fbb99ae7014a91d9b1d535389902:

  1. Whole whitespace-delimited token matching prevents partial identifier edits; the new streaming KMP matcher handles large inputs without compiling request-sized regexes.
  2. Ambiguous exact matches keep a count and at most five offsets, rather than allocating one object per hit.
  3. Preview-only workers can negotiate tolerant_match and replace_all without advertising the edit-only hash feature.
  4. The worker rejects unnegotiated matching and replaceAll before executing or arming an edit, even when those fields specify exact or false.
  5. KMP searches line-window candidates in linear time; repetitive indentation verification is bounded and fails closed with a useful conflict.

I also fixed an additional resource-limit issue: replaceAll now rejects an oversized intermediate before allocating a giant replacement. Previews and edits remain non-mutating on rejection. Existing legacy result shapes are unchanged.

Verification at this exact head: code-package tsc --noEmit passed; 99 focused engine/protocol/worker tests and 10 workspace integration tests passed; three restricted-heap regressions passed; 91 focused service tests and touched-file ESLint passed. Service tsc --noEmit still reports seven errors in pre-existing unrelated files. CI has nine passing checks; Lambda MicroVM Runner Image (arm64) is still running. Full suites, the code-package lint/import-sort (no configured executable or script), and a fresh Codex review were not run here. The GitHub App cannot trigger Codex; a maintainer can request it if desired.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review the latest head

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d1a44ecfb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/code/src/edits.ts Outdated

@lia-by-librechat lia-by-librechat Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed the Codex CRLF/LF boundary finding at exact head 4b677dfcf5266e86ced295cbd1576e18ba9c89fb. The regression failed before the fix and now passes. The whitespace-normalized tier normalizes caller newline forms before peeling, retains each matched line's ending in mixed files, and keeps exact-mode replacements unchanged.

The wider edit-subsystem review also found and fixed silent boundary errors: a token-only match no longer accepts oldText boundary whitespace or line breaks absent from the source, and rejects caller attempts to remove that out-of-range whitespace. Multiple claimed line breaks must actually exist. Previews and edits fail with EDIT_CONFLICT without modifying the file. BOM, batch operations, replaceAll, mixed line endings, wire results, and many-match performance have regressions.

Local checks: code-package TypeScript passed; 119 focused matcher/protocol/worker tests and 22 workspace tests passed; 83 service tests passed before the final boundary-only edit; service typecheck retains its seven pre-existing errors. Exact-head CI may still be running. Full suites, code-package lint/import-sort (not configured), and a new Codex review were not run; a maintainer must trigger Codex.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review the latest head

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b677dfcf5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const newline = text.indexOf('\n', start);
const lineEnd = newline < 0 ? text.length : newline;
const end = lineEnd > start && text[lineEnd - 1] === '\r' ? lineEnd - 1 : lineEnd;
lines.push({ start, end, next: newline < 0 ? undefined : newline + 1, text: text.slice(start, end) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound line materialization for newline-dense files

Although the KMP change fixes repeated window comparisons, a permitted 1 MiB file containing mostly one-character lines still makes this loop allocate roughly one million Line objects per pass. A missing exact edit invokes both relaxed line strategies from describeMissing and then splits the file again in nearestLine; batches of up to 100 failures repeat that work, so a valid request can cause hundreds of megabytes of allocation or stall/OOM the worker instead of returning EDIT_CONFLICT. Reuse a compact line index across strategies or cap diagnostic materialization.

Useful? React with 👍 / 👎.

Comment on lines +387 to +389
} else if (leadingNewlines === 1 && newText.startsWith('\n')) {
// Keep the source's indentation when the caller used different spaces.
newText = newText.slice(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip the full equivalent leading whitespace wrapper

When oldText and newText each start with one newline but use different indentation after it, this fallback removes only the newline from newText. Because the source's entire boundary whitespace remains outside the matched token range, the replacement then contains both indentations; for example, source "header\n foo bar\n", oldText: "\n foo bar", and newText: "\n\tbaz qux" produces "header\n \tbaz qux\n". This can change block structure in indentation-sensitive files, so peel the complete leading whitespace run corresponding to that shared boundary rather than only \n.

Useful? React with 👍 / 👎.

hints.push(
nearest.exact
? `its first line appears at ${formatLineList(text, nearest.starts, nearest.count)}, but the lines after it differ`
: `the closest line is ${formatLineList(text, nearest.starts)}: ${snippet(nearest.text)}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid returning source snippets from edit-only workspaces

When a worker advertises edit_file without read_file or preview_edit, which the capability validator permits and service/src/bridge/store.ts:139-144 routes based only on the requested operation, a caller can submit guaranteed-missing oldText containing two probe tokens. Because nearestLine accepts 50% token overlap, this branch then returns up to 120 characters of the actual source line in the conflict message, turning edit permission into a read primitive. Omit source text from edit diagnostics unless read access was independently authorized.

Useful? React with 👍 / 👎.

Comment on lines +164 to +168
for (
let start = text.indexOf(edit.oldText);
start >= 0;
start = text.indexOf(edit.oldText, start + step)
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Count overlapping exact matches in linear time

The bounded-range fix still restarts indexOf at every code-unit offset to obtain the full ambiguity count. For a permitted 1 MiB file of repeated a characters and a 512 KiB oldText of a characters, there are roughly 512,000 overlapping matches and every search compares another long suffix, making this path quadratic; even substantially smaller inputs already take seconds before an EDIT_CONFLICT can be returned. Use a linear matcher such as KMP to count overlapping exact matches rather than repeatedly rescanning the same text.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants