Summary
Add a search/replace-block (and/or unified-diff) edit tool, modeled on Aider's edit formats, selectable for models too weak to use the existing XML/JSON tool-call path or the exact-string string_replace editor. Wired through the existing tool-profile system so tiny models can drop down to this lower rung automatically.
Context
The codebase today has a single content-based editor (source/tools/file-ops/string-replace.tsx) that demands an exact old_str/new_str match. That works well for capable models but is fragile on weak local models that hallucinate whitespace, drift indentation, or struggle to reproduce long blocks byte-for-byte. The system prompt and tool definitions, by contrast, already bend toward weak models: source/tools/tool-profiles.ts:23-30 defines a nano profile (≤4B params, 5 tools, ultra-slim prompt, single-tool mode) and the prompt has parallel ultra-slim variants for nano (see isNanoProfile in source/tools/tool-profiles.ts:135-137).
The edit tool itself does not bend — it is the same exact-match tool for nano models as for everything else. The local-tool-calling-reliability story already has multiple tiers (native function calling → XML fallback → JSON fallback, with malformed-output repair on both — see docs/battlemap.md:181-183). The edit tool needs its own rung below that: a diff-block format that Aider proved works on tiny local models. search_file_contents for search/replace|diff.*format|edit_format in source/ returned no hits, so nothing in this shape exists yet.
Concretely, the work would land in three places:
- A new tool at
source/tools/file-ops/diff-edit.tsx (alongside source/tools/file-ops/string-replace.tsx and source/tools/file-ops/write-file.tsx), exporting a NanocoderToolExport per the pattern in source/types/core.ts:194. It can reuse createFileToolApproval('diff_edit'), hasSeenFile/markFileSeen, getCachedFileContent/invalidateCache (source/utils/read-tracker.ts, source/utils/file-cache.ts), and the VS Code preview integration via sendFileChangeToVSCode already wired up in source/tools/file-ops/string-replace.tsx.
- Registration in
source/tools/index.ts:25-41 (staticTools array — the single source of truth).
- The profile and inference tables in
source/tools/tool-profiles.ts:23-30 and source/types/config.ts:239 (ToolProfile = 'auto' | 'full' | 'minimal' | 'nano'), so that the new tool is gated to the right tier and the drift-guard test at source/tools/tool-profiles.spec.ts:11-26 ("every profile names only registered tools") continues to pass.
Proposed approach
- Pick a single primary format to ship first (search/replace-block is the smaller surface; unified-diff is the more powerful superset). Recommended: search/replace-block with the model emitting fenced blocks like:
<<<<<<< SEARCH
...exact old content...
=======
...new content...
>>>>>>> REPLACE
Apply it line-by-line, validate that the search block is unique in the file (same uniqueness rule string-replace.tsx:42-50 already enforces), and reuse the read-before-edit gate from string-replace.tsx:218-226 (hasSeenFile).
- Keep
string_replace as-is. The new tool is an additional rung, not a replacement. Selectable per-model: extend ToolProfile (or add a sibling setting editFormat: 'string_replace' | 'diff_block') so the model-size heuristic in inferToolProfile (source/tools/tool-profiles.ts:95-104) can map ≤4B → nano profile that includes diff_edit, ≤15B → minimal profile that includes both editors, larger models → default string_replace only.
- Mirror the full
NanocoderToolExport shape from source/tools/file-ops/string-replace.tsx:268-274: tool, formatter (render a colour-coded unified-style preview via formatStringReplacePreview patterns at source/tools/file-ops/string-replace-preview.tsx), validator, approval.
- Apply the patch atomically: parse all SEARCH/REPLACE blocks, validate every search block is found and unique before any write, then
writeFile once and invalidateCache + markFileSeen per source/tools/file-ops/string-replace.tsx:57-67. Return a line-range summary modelled on string-replace.tsx:69-95 so the model gets the same updated-file feedback.
- Extend
TOOL_PROFILES and TOOL_PROFILE_DESCRIPTIONS / TOOL_PROFILE_TOOLTIPS in source/tools/tool-profiles.ts:23-45 so the new tool is documented as part of nano.
- Add tests alongside the existing
string-replace.spec.tsx pattern (parser fuzz tests against the same content the validator uses, uniqueness rejection, missing-search-block rejection, atomic-apply semantics) and add diff_edit to the drift-guard expectations.
Acceptance criteria
- A new
diff_edit tool is exported from source/tools/file-ops/ and registered in source/tools/index.ts, conforming to the NanocoderToolExport shape at source/types/core.ts:194.
- The tool accepts a
path and a diff (or blocks) string containing one or more SEARCH/REPLACE fenced blocks, parses them, validates every search block exists exactly once in the on-disk file, applies them atomically, and writes the result via the existing writeFile + invalidateCache + markFileSeen path.
- The validator enforces the same read-before-edit gate (
hasSeenFile) used by source/tools/file-ops/string-replace.tsx:218-226.
- The tool is included in the
nano profile in source/tools/tool-profiles.ts and is selectable (either as part of nano or via an explicit editFormat setting) for weak local models; string_replace remains the default for minimal and full.
source/tools/tool-profiles.spec.ts continues to pass, including the drift-guard test that every name in a profile resolves to a registered tool.
/tune's profile picker (source/app/components/tune-selector.tsx) reflects the change in description/tooltip, with wording consistent with the existing TOOL_PROFILE_DESCRIPTIONS / TOOL_PROFILE_TOOLTIPS copy.
- New spec file covers: single-block apply, multi-block atomic apply, search-block not found, search-block ambiguous (>1 match), empty
path, unread file rejected by validator, and malformed input (unterminated fence, missing separator) rejected with a clear error.
- An end-to-end smoke test exercises a model emitting a diff block against a real file and asserts the post-edit content matches the union of the REPLACE blocks.
Out of scope
- Replacing
string_replace or any change to its behaviour for existing profiles.
- Native (non-tool-call) streaming of diff blocks into the conversation; the first cut routes through the same tool-call pipeline as other editors.
- Full unified-diff (
--- a/ +++ b/ @@ hunks) parser — ship SEARCH/REPLACE first; unified-diff can follow if needed.
- Changes to the XML/JSON tool-call fallback parsers in
source/hooks/chat-handler/conversation/.
Summary
Add a search/replace-block (and/or unified-diff) edit tool, modeled on Aider's edit formats, selectable for models too weak to use the existing XML/JSON tool-call path or the exact-string
string_replaceeditor. Wired through the existing tool-profile system so tiny models can drop down to this lower rung automatically.Context
The codebase today has a single content-based editor (
source/tools/file-ops/string-replace.tsx) that demands an exactold_str/new_strmatch. That works well for capable models but is fragile on weak local models that hallucinate whitespace, drift indentation, or struggle to reproduce long blocks byte-for-byte. The system prompt and tool definitions, by contrast, already bend toward weak models:source/tools/tool-profiles.ts:23-30defines ananoprofile (≤4B params, 5 tools, ultra-slim prompt, single-tool mode) and the prompt has parallel ultra-slim variants fornano(seeisNanoProfileinsource/tools/tool-profiles.ts:135-137).The edit tool itself does not bend — it is the same exact-match tool for nano models as for everything else. The local-tool-calling-reliability story already has multiple tiers (native function calling → XML fallback → JSON fallback, with malformed-output repair on both — see
docs/battlemap.md:181-183). The edit tool needs its own rung below that: a diff-block format that Aider proved works on tiny local models.search_file_contentsforsearch/replace|diff.*format|edit_formatinsource/returned no hits, so nothing in this shape exists yet.Concretely, the work would land in three places:
source/tools/file-ops/diff-edit.tsx(alongsidesource/tools/file-ops/string-replace.tsxandsource/tools/file-ops/write-file.tsx), exporting aNanocoderToolExportper the pattern insource/types/core.ts:194. It can reusecreateFileToolApproval('diff_edit'),hasSeenFile/markFileSeen,getCachedFileContent/invalidateCache(source/utils/read-tracker.ts,source/utils/file-cache.ts), and the VS Code preview integration viasendFileChangeToVSCodealready wired up insource/tools/file-ops/string-replace.tsx.source/tools/index.ts:25-41(staticToolsarray — the single source of truth).source/tools/tool-profiles.ts:23-30andsource/types/config.ts:239(ToolProfile = 'auto' | 'full' | 'minimal' | 'nano'), so that the new tool is gated to the right tier and the drift-guard test atsource/tools/tool-profiles.spec.ts:11-26("every profile names only registered tools") continues to pass.Proposed approach
string-replace.tsx:42-50already enforces), and reuse the read-before-edit gate fromstring-replace.tsx:218-226(hasSeenFile).string_replaceas-is. The new tool is an additional rung, not a replacement. Selectable per-model: extendToolProfile(or add a sibling settingeditFormat: 'string_replace' | 'diff_block') so the model-size heuristic ininferToolProfile(source/tools/tool-profiles.ts:95-104) can map ≤4B → nano profile that includesdiff_edit, ≤15B → minimal profile that includes both editors, larger models → defaultstring_replaceonly.NanocoderToolExportshape fromsource/tools/file-ops/string-replace.tsx:268-274:tool,formatter(render a colour-coded unified-style preview viaformatStringReplacePreviewpatterns atsource/tools/file-ops/string-replace-preview.tsx),validator,approval.writeFileonce andinvalidateCache+markFileSeenpersource/tools/file-ops/string-replace.tsx:57-67. Return a line-range summary modelled onstring-replace.tsx:69-95so the model gets the same updated-file feedback.TOOL_PROFILESandTOOL_PROFILE_DESCRIPTIONS/TOOL_PROFILE_TOOLTIPSinsource/tools/tool-profiles.ts:23-45so the new tool is documented as part ofnano.string-replace.spec.tsxpattern (parser fuzz tests against the same content the validator uses, uniqueness rejection, missing-search-block rejection, atomic-apply semantics) and adddiff_editto the drift-guard expectations.Acceptance criteria
diff_edittool is exported fromsource/tools/file-ops/and registered insource/tools/index.ts, conforming to theNanocoderToolExportshape atsource/types/core.ts:194.pathand adiff(orblocks) string containing one or more SEARCH/REPLACE fenced blocks, parses them, validates every search block exists exactly once in the on-disk file, applies them atomically, and writes the result via the existingwriteFile+invalidateCache+markFileSeenpath.hasSeenFile) used bysource/tools/file-ops/string-replace.tsx:218-226.nanoprofile insource/tools/tool-profiles.tsand is selectable (either as part ofnanoor via an expliciteditFormatsetting) for weak local models;string_replaceremains the default forminimalandfull.source/tools/tool-profiles.spec.tscontinues to pass, including the drift-guard test that every name in a profile resolves to a registered tool./tune's profile picker (source/app/components/tune-selector.tsx) reflects the change in description/tooltip, with wording consistent with the existingTOOL_PROFILE_DESCRIPTIONS/TOOL_PROFILE_TOOLTIPScopy.path, unread file rejected by validator, and malformed input (unterminated fence, missing separator) rejected with a clear error.Out of scope
string_replaceor any change to its behaviour for existing profiles.--- a/+++ b/@@hunks) parser — ship SEARCH/REPLACE first; unified-diff can follow if needed.source/hooks/chat-handler/conversation/.