A valid multi-file edit is silently discarded if the response merely mentions an unrelated existing file (whole/editor-whole formats, non-interactive --yes-always)
Issue
Environment
- aider 0.86.2
- Python 3.12.11
- Reproduced with a local OpenAI-compatible model (llama.cpp server) as the editor model in
--architectmode,--editor-edit-format editor-whole,--yes-always,--no-show-model-warnings. Also reasoned through and confirmed to apply to thewholeedit format directly (not just the--architect/editor-wholecombination).
Summary
When a model's response is otherwise a perfectly valid whole/editor-whole edit, but the response text also happens to contain, anywhere, a string that matches the relative path or unique basename of some other file in the repo, check_for_file_mentions() treats that as an implicit request to add the file to the chat. Under --yes-always this is auto-confirmed, and the entire current response is discarded (via reflected_message) before apply_updates() is ever called — including the edits that had nothing to do with the mentioned file. In the worst case (many mentions at once) this triggers a token-limit crash on the next turn and the session ends having applied nothing at all, including the parts of the response that were completely correct.
This is not a model-quality problem: the model can behave perfectly and still lose its own valid edit, because the file-mention detection is a naive whole-text-token scan with no way to distinguish "this filename appears because it's part of the normal, unchanged content of a file I'm dutifully reproducing in full" from "I am asking for this file's content."
Minimal reproduction (no LLM required — pure text-matching bug)
The core issue is fully deterministic and reproducible without ever calling a model, by exercising get_file_mentions() directly against a toy repo:
from aider.coders.base_coder import Coder
from aider.io import InputOutput
from aider.models import Model
# any repo with at least: Makefile, src/foo.c, include/bar.h, include/sub/baz.h
io = InputOutput(yes=True)
model = Model("gpt-4o") # never actually called
coder = Coder.create(main_model=model, io=io, fnames=[], use_git=True)
# (1) A line that would appear, unchanged, in a Makefile rule for a
# *different* target than the one being edited:
print(coder.get_file_mentions("cc -o out src/foo.c\n"))
# -> {'src/foo.c'} (matches: exact repo-root-relative path)
# (2) A flat #include of a top-level header, in a .c file whose *own* edit
# has nothing to do with bar.h:
print(coder.get_file_mentions('#include "bar.h"\n'))
# -> {'include/bar.h'} (matches: unique basename)
# (3) The same header, but included via a subdirectory-relative path
# (relative to the compiler's -I search path, not the repo root):
print(coder.get_file_mentions('#include "sub/baz.h"\n'))
# -> set() (neither strategy matches - this one "accidentally" escapes)
Case (1) and (2) both register a "mention" purely because the string happens to be present, with zero regard for why it's present. In a real editing session, (1) and (2) are exactly what happens whenever a model reproduces, in full, a file that has pre-existing references to other files it was never asked to touch (a Makefile with several build targets, a source file with several #includes of top-level headers, a CMakeLists.txt listing other sources, an __init__.py re-exporting other modules, etc.).
Real-world consequence
- User asks for 3 things: edit
include/bar.h, create a new test file, add a target toMakefile. - The editor model does all 3 correctly. Because it's rewriting the whole Makefile (required by
whole/editor-whole), the unchanged parts of the Makefile still contain build-dependency lines naming several other pre-existing source files. check_for_file_mentions()on the model's own response (base_coder.py,check_for_file_mentionsat line 1761, called fromsend_message's tail around line 1561-1566) picks up every one of those pre-existing filenames.- Under
--yes-always,confirm_ask("Add file to the chat?", ...)(io.py,if self.yes is True: res = "y"around line 866/940) auto-confirms adding all of them, with no cap. - Back in
run_one(base_coder.py),reflected_messageis set and the function returns beforeapply_updates()is reached — the correct edit for all 3 originally-requested files is thrown away, and a new turn starts with all those extra files now injected as full content. - If the model responds to that turn by (reasonably, given its system prompt:
wholefile_prompts.py'ssystem_reminderliterally says "Output a copy of each file that needs changes") producing content for some of those newly-injected files too, the cycle can repeat and blow through the model's context window well beforemax_reflections(default 3,base_coder.py:101) is ever reached. The session then ends having applied nothing: not the newly-requested edits, not the incidentally-fetched files, nothing.
We verified this end-to-end with a real (small, local) model: a 3-file task that the model completed correctly on its first attempt still resulted in zero applied edits and zero commits, twice, purely because of this mechanism.
Why --yes-always makes it worse, but doesn't cause it
Note that check_for_file_mentions() only runs on the editor's own generated reply in the --architect/auto_accept_architect flow, because ArchitectCoder.reply_completed() calls editor_coder.run(with_message=content, preproc=False) (architect_coder.py:44) — preproc=False skips scanning the incoming instruction for mentions. So the only place this fires is on the editor's own output, and it is also the only mechanism by which the editor model can legitimately obtain the content of a pre-existing file it needs to edit but wasn't given up front. This means a blanket "never auto-confirm" patch (e.g. forcing explicit_yes_required=True on that one confirm_ask call) is not a safe fix — it would break the common, legitimate case (editor mentions a file it genuinely needs) along with the pathological one.
Suggested directions
- Apply first, reflect second. Reorder so
apply_updates()runs on the current response beforecheck_for_file_mentions()can short-circuit it. A response that contains valid, applicable edits should never be discarded wholesale just because it also mentions an unrelated file elsewhere in its text. Any genuinely-needed file could still be fetched for a following turn, without cost to the edits that already succeeded. This seems like the smallest, safest change and would have prevented 100% of what we observed. - Cap the blast radius per turn. If a single response newly mentions more than some small N files (e.g. 2-3) at once, treat it as noise rather than genuine intent and don't add any of them / don't reflect. A real, deliberate "I need file X" request is almost always one file, not a dozen.
- An explicit, unambiguous protocol for the editor to request a file's content (e.g. a distinct marker like
FILE-REQUEST(path)) instead of inferring intent from incidental text matching. This is the most robust fix but also the most invasive (changes the contract with every edit-format's system prompt). - At minimum, document this failure mode for anyone running aider non-interactively (
--yes-always, CI/agentic pipelines): whole-file reproduction of any file that itself references other project files (build files,__init__.py, headers with flat includes, etc.) is currently unsafe and can silently discard otherwise-correct multi-file edits.
Happy to share the full verbose logs of his issue if usefu and hope this info could help!
Version and model info
No response
Source: Aider-AI/aider