[go: up one dir, main page]

DEV Community

Masih Maafi
Masih Maafi

Posted on Originally published at masihmoafi.com Fully Autonomous

How Aider works under the hood

Codex and Aider look alike from the outside: you type, the model edits your code. Inside
they are opposite designs. In Codex the model has tools and explores the repository
itself. In Aider the model has no tools at all. The harness decides what the model
sees, the model answers in plain text, and the harness finds the edits in that text and
applies them.

This post follows one message through Aider's Python source, the same way my
Codex and Elpis post does for Codex. Code is quoted
verbatim from Aider 0.86.3.dev, commit 5dc9490, with paths relative to the
repository root. Nothing here was run; it is read from the source.

1. The shape: one object per mode

main() builds a single Coder and loops on coder.run().
Each edit format is its own Coder subclass with its own prompts and parser, so a mode
change such as /ask or /architect is not a flag. It raises SwitchCoder, and main
builds a new Coder from the old one, copying the files, history and cost across
(in Coder.create).

# aider/main.py
while True:
    try:
        coder.ok_to_warm_cache = bool(args.cache_keepalive_pings)
        coder.run()
        analytics.event("exit", reason="Completed main CLI coder.run")
        return
    except SwitchCoder as switch:
# …
        kwargs = dict(io=io, from_coder=coder)
        kwargs.update(switch.kwargs)
# …
        coder = Coder.create(**kwargs)
Enter fullscreen mode Exit fullscreen mode

If the edit format
changed, the old history is summarized first, because the code's own comment says the old
format "will confused the new LLM. It may try and imitate it".

# aider/coders/base_coder.py
# If the edit format changes, we can't leave old ASSISTANT
# messages in the chat history. The old edit format will
# confused the new LLM. It may try and imitate it, disobeying
# the system prompt.
done_messages = from_coder.done_messages
if edit_format != from_coder.edit_format and done_messages and summarize_from_coder:
    try:
        done_messages = from_coder.summarizer.summarize_all(done_messages)
Enter fullscreen mode Exit fullscreen mode

2. What the model sees

Flowchart of one Aider turn: preprocess the message, build the request, send and stream the reply, check for newly named files, apply and commit edits, lint and test, then either send problems back as the next message, up to three times, or move the turn into history.

Diagram, continued (part 2 of 3)

Diagram, continued (part 3 of 3)

The request is assembled by ChatChunks in a fixed order: system prompt, examples,
read-only files, the repo map, past history, the editable files, the current turn, and
last a reminder of the edit-format rules.

# aider/coders/chat_chunks.py
def all_messages(self):
    return (
        self.system
        + self.examples
        + self.readonly_files
        + self.repo
        + self.done
        + self.chat_files
        + self.cur
        + self.reminder
    )
Enter fullscreen mode Exit fullscreen mode

done is the past history and cur is the current turn. Stable
parts come first and volatile ones last, so the prompt cache keeps a long prefix (my
inference from the caching code below). Notice that the editable files sit after the
history, so the model reads the newest version of a file right next to your request.

Context blocks are faked as conversation: a message such as "here are the files" followed
by an invented "Ok." from the assistant. A history summary comes back the same way, as a
user message beginning "I spoke to you previously about..." (see summarize_all in
aider/history.py and summary_prefix in aider/prompts.py).
The model sees these as things that were said, not as notes from the system.

The whole conversation goes to litellm, one API over many providers, at temperature 0
by default (in Model.send_completion). Retryable errors back off from 0.125 seconds,
doubling until the delay passes 60.

# aider/models.py
RETRY_TIMEOUT = 60
# …
        retry_delay = 0.125
# …
                if should_retry:
                    retry_delay *= 2
                    if retry_delay > RETRY_TIMEOUT:
                        should_retry = False
Enter fullscreen mode Exit fullscreen mode

3. No tools, on purpose

The base class sets functions = None, and the reflection limit sits right next to it.

# aider/coders/base_coder.py
functions = None
# …
num_reflections = 0
max_reflections = 3
Enter fullscreen mode Exit fullscreen mode

Three coders
once used JSON function calls to edit, but they are out of the registry, and two raise
"Deprecated" when built (in editblock_func_coder.py and wholefile_func_coder.py). The only reason the
repository gives is a changelog note from v0.7.0 in HISTORY.md: "Initial experiments show that using
functions makes 3.5 less competent at coding".

So the model writes edits as fenced text, and Aider finds them with regular expressions.
The nearest thing to a shell tool is a fenced bash block in the reply, which runs only if you
say yes (in run_shell_commands). Its output goes into the next turn, not
back to the model at once.

That changes what "agentic" means. Codex loops until the model stops asking for tools.
Aider's only loop is a reflection: after a turn, if Aider itself found a problem, it
sends the problem back as the next user message, at most three times
(the max_reflections = 3 above). Four things count as a problem: an edit that
would not parse or match, a repo file the model named that is not in the chat, lint
errors, and test errors. They all share the same budget of three.

That second one is how the model "opens a file". It cannot call a read tool, so it names
the file in its reply. Aider notices, asks you whether to add it, and replays the turn.
The check runs before any edit is applied, so an otherwise valid edit in that same reply
is thrown away.

# aider/coders/base_coder.py
if not interrupted:
    add_rel_files_message = self.check_for_file_mentions(content)
    if add_rel_files_message:
        if self.reflected_message:
            self.reflected_message += "\n\n" + add_rel_files_message
        else:
            self.reflected_message = add_rel_files_message
        return
Enter fullscreen mode Exit fullscreen mode

The early return comes before apply_updates() is ever called. A person is the gate where
Codex would have a tool call.

4. From a reply to a change on disk

Flowchart of how Aider applies an edit: parse edit blocks from the reply text, dry-run to find each real target file, ask permission for new or unadded files, match and write each block, collect an error with near-miss lines for blocks that fail to match, auto-commit what was written, then send any errors back to the model.

Diagram, continued (part 2 of 2)

The default format is SEARCH/REPLACE: the model quotes the lines to change, then the
lines to put there. The parser is loose on purpose, accepting five to nine marker
characters and hunting up to three lines above a block for its filename, because one
model kept misplacing them (in find_original_update_blocks and find_filename).

# aider/coders/editblock_coder.py
HEAD = r"^<{5,9} SEARCH>?\s*$"
DIVIDER = r"^={5,9}\s*$"
UPDATED = r"^>{5,9} REPLACE\s*$"
# …
                # if next line after HEAD exists and is DIVIDER, it's a new file
                if i + 1 < len(lines) and divider_pattern.match(lines[i + 1].strip()):
                    filename = find_filename(lines[max(0, i - 3) : i], fence, None)
                else:
                    filename = find_filename(lines[max(0, i - 3) : i], fence, valid_fnames)
Enter fullscreen mode Exit fullscreen mode

Note the {5,9} in each pattern and the i - 3 window for the filename.

Matching an imperfect SEARCH tries a fixed sequence: an exact line match, then the same
match with the leading indentation repaired ("GPT often messes up leading whitespace",
says the comment), then the same again without a stray blank line, then a ... elision
that requires each piece to appear exactly once (in replace_most_similar_chunk).

# aider/coders/editblock_coder.py
res = perfect_or_whitespace(whole_lines, part_lines, replace_lines)
# …
if len(part_lines) > 2 and not part_lines[0].strip():
    skip_blank_line_part_lines = part_lines[1:]
    res = perfect_or_whitespace(whole_lines, skip_blank_line_part_lines, replace_lines)
# …
    res = try_dotdotdots(whole, part, replace)
# …
return
# Try fuzzy matching
res = replace_closest_edit_distance(whole_lines, part, part_lines, replace_lines)
Enter fullscreen mode Exit fullscreen mode

perfect_or_whitespace covers the first two steps. Keep an eye on the bare return
near the end; section 10 comes back to it.

When a block fails, the error the model receives is written to be fixable: each failed
block echoed back, the closest real lines in the file, a reminder that SEARCH must match
exactly including whitespace, and "The other N blocks were applied successfully. Don't
re-send them." (in EditBlockCoder.apply_edits).

# aider/coders/editblock_coder.py
        res += (
            "The SEARCH section must exactly match an existing block of lines including all white"
            " space, comments, indentation, docstrings, etc\n"
        )
        if passed:
            pblocks = "block" if len(passed) == 1 else "blocks"
            res += f"""
# The other {len(passed)} SEARCH/REPLACE {pblocks} were applied successfully.
Don't re-send them.
Just reply with fixed versions of the {blocks} above that failed to match.
Enter fullscreen mode Exit fullscreen mode

Aider also has unified-diff, whole-file and OpenAI-style patch formats. The whole-file
format has no matching step at all: the file is simply overwritten.

5. The repo map: context without a model choosing it

Flowchart of how Aider builds its repo map: extract definitions and references with tree-sitter, cache them, build a graph of files linked by shared identifiers, weight the edges, run personalized PageRank, then binary search for the largest map that fits the token budget.

Diagram, continued (part 2 of 3)

Diagram, continued (part 3 of 3)

Aider never lets the model search the repository, so it hands over a map. For every file
it uses tree-sitter to pull out definitions and references, and caches them on disk
keyed by modification time (in RepoMap.get_tags and get_tags_raw, in aider/repomap.py).

Then it builds a graph. Files are nodes, and an identifier that one file defines and
another references becomes an edge from the referencing file to the defining one. The
weight is a multiplier times the square root of the number of references, so a name
repeated a hundred times does not drown out the rest. The multiplier is 10 if you
mentioned the name, 10 if it looks distinctive (long snake, kebab or camel case), 0.1 if
it starts with an underscore, 0.1 if more than five files define it, and a further 50 if
the edge comes from a file already in the chat (in RepoMap.get_ranked_tags).

# aider/repomap.py
if ident in mentioned_idents:
    mul *= 10
if (is_snake or is_kebab or is_camel) and len(ident) >= 8:
    mul *= 10
if ident.startswith("_"):
    mul *= 0.1
if len(defines[ident]) > 5:
    mul *= 0.1
# …
        if referencer in chat_rel_fnames:
            use_mul *= 50
# …
        num_refs = math.sqrt(num_refs)

        G.add_edge(referencer, definer, weight=use_mul * num_refs, ident=ident)
Enter fullscreen mode Exit fullscreen mode

"Long" means at least eight characters.

Personalized PageRank then ranks the files, seeded by the chat files and anything you
mentioned (the nx.pagerank call in the same function). No language model is involved. The score is split
over each file's definitions, definitions in chat files are dropped because their full
text is already sent, and Aider binary-searches for the largest map that fits the token
budget, stopping once it is within 15% of it (in RepoMap.get_ranked_tags_map_uncached).

# aider/repomap.py
pct_err = abs(num_tokens - max_map_tokens) / max_map_tokens
ok_err = 0.15
if (num_tokens <= max_map_tokens and num_tokens > best_tree_tokens) or pct_err < ok_err:
    best_tree = tree
    best_tree_tokens = num_tokens

    if pct_err < ok_err:
        break
Enter fullscreen mode Exit fullscreen mode

The budget is
an eighth of the model's input window, clamped between 1,024 and 4,096 tokens.

# aider/models.py
def get_repo_map_tokens(self):
    map_tokens = 1024
    max_inp_tokens = self.info.get("max_input_tokens")
    if max_inp_tokens:
        map_tokens = max_inp_tokens / 8
        map_tokens = min(map_tokens, 4096)
        map_tokens = max(map_tokens, 1024)
    return map_tokens
Enter fullscreen mode Exit fullscreen mode

6. Keeping the window under control

Aider never trims context by itself. Before each request check_tokens() only warns,
"Try to proceed anyway?", and tells you to /drop or /clear.
If the provider still reports an exceeded window, the turn simply ends.

The one thing a model does manage is chat history. Once past history exceeds a sixteenth
of the context window, clamped to between 1,024 and 8,192 tokens, a background thread
summarizes the older part with the cheap "weak" model, keeping a recent tail of about
half the budget word for word (the limit is set in the Model constructor; the split is in
ChatSummary.summarize_real in aider/history.py).

For cost, --cache-prompts places Anthropic-style cache markers on up to three
prefixes (examples, repo map, editable files), and a daemon thread pings the model every
five minutes minus five seconds with a one-token request to keep the cache warm while you
think (in ChatChunks.add_cache_control_headers and Coder.warm_cache).

# aider/coders/base_coder.py
delay = 5 * 60 - 5
delay = float(os.environ.get("AIDER_CACHE_KEEPALIVE_DELAY", delay))
# …
        kwargs = dict(self.main_model.extra_params) or dict()
        kwargs["max_tokens"] = 1
Enter fullscreen mode Exit fullscreen mode

Codex aims at the same goal differently:
it re-sends the whole history with a prompt_cache_key and lets the provider's cache do
the rest.

7. Git as the safety net

Aider has no sandbox; a search of its source finds none. Its safety net is git. Before
editing a file it commits any uncommitted changes you had in it, so each edit gets its own
clean commit. After writing, it commits the result with a message written by a model, and
tells the model the hash (in check_for_dirty_commit and auto_commit, and GitRepo.commit in aider/repo.py).
/undo will only revert a commit if it is one of this session's own, has a single
parent, has not been pushed, and its files are clean (in raw_cmd_undo in aider/commands.py).

8. Architect and editor: two models, one handoff

In architect mode a strong model plans and a second model writes the edits. The architect
produces no edits itself. When it finishes it asks "Edit the files?", then starts a new
Coder on the editor model with no history and no repo map, and passes the architect's
whole reply to it as the user message (in ArchitectCoder.reply_completed).

# aider/coders/architect_coder.py
editor_model = self.main_model.editor_model or self.main_model
# …
kwargs["main_model"] = editor_model
kwargs["edit_format"] = self.main_model.editor_edit_format
kwargs["suggest_shell_commands"] = False
kwargs["map_tokens"] = 0
# …
editor_coder = Coder.create(**new_kwargs)
editor_coder.cur_messages = []
editor_coder.done_messages = []
# …
editor_coder.run(with_message=content, preproc=False)
Enter fullscreen mode Exit fullscreen mode

map_tokens = 0 turns the repo map off, the two message lists are emptied, and content
is the architect's reply. My reading
is that this lets a strong reasoning model be paired with one that is better at the
edit format, though the code does not say so.

9. Side by side

Aider Codex
Who explores the repo The harness, through a repo map and the files you add The model, through shell and read tools
How edits happen Parsed from the model's text A tool call (apply_patch)
The loop At most 3 harness-driven reflections Until the model stops calling tools
Safety A git commit per edit, and your yes before any command the model suggests An approval policy and an OS sandbox
Long sessions You manage the file set; old history is summarized in the background The window is compacted at 90%, and the model's own outputs are dropped
Providers litellm, one call for many providers The Responses API, with adapters added in Elpis

Neither is simply better. Aider's design gives up autonomy for predictability: roughly one
model call per turn plus at most three reflections, and a human at every decision that
reaches outside the chat. Codex's gives the model room to explore and pays for it with a sandbox, an approval
system and a longer context.

10. Things you would not guess

  • Fuzzy matching is switched off. A comment reads "Try fuzzy matching", but a bare return sits just above the call, so it never runs. Only the error hint uses similarity (see the end of replace_most_similar_chunk in section 4).
  • A reply can leave a half-applied commit. Blocks that match are written to disk one by one, and the result is auto-committed before the failures are sent back to be retried (in EditBlockCoder.apply_edits and Coder.send_message).
  • A wrong filename is quietly forgiven. If a SEARCH does not match its named file, Aider tries every other file in the chat and applies the edit to the first that matches (in EditBlockCoder.apply_edits).
  • The context guard fails open. If token counting throws, it prints a warning and returns 0, so the check passes (in Model.token_count).
  • The unified-diff format replaces every match. The uniqueness check is commented out and the code calls str.replace; the SEARCH/REPLACE format replaces only the first.
# aider/coders/search_replace.py
def search_and_replace(texts):
    search_text, replace_text, original_text = texts

    num = original_text.count(search_text)
    # if num > 1:
    #    raise SearchTextNotUnique()
    if num == 0:
        return

    new_text = original_text.replace(search_text, replace_text)

    return new_text
Enter fullscreen mode Exit fullscreen mode
  • /drop a.py also drops data.py. Dropping matches by substring of the path, where /add matches exactly or by glob (in cmd_drop).
# aider/commands.py
# For editable files, use glob if word contains glob chars, otherwise use substring
if any(c in expanded_word for c in "*?[]"):
    matched_files = self.glob_filtered_to_repo(expanded_word)
else:
    # Use substring matching like we do for read-only files
    matched_files = [
        self.coder.get_rel_fname(f) for f in self.coder.abs_fnames if expanded_word in f
    ]
Enter fullscreen mode Exit fullscreen mode

What this does not cover

I did not trace ContextCoder, watch mode's internals beyond the outline, voice input,
the web scraper, or analytics. Aider can be run with a different edit format per model,
and the defaults live in a settings file I read only through git.


Originally published at https://masihmoafi.com/blog/aider-under-the-hood.

Top comments (0)