Modernizing Complex Legacy Code with AI Agents: Mistral's Approach

Mistral has published research on using AI agents to modernize complex legacy codebases. Rather than treating migration as a single rewrite, the approach decomposes large, tangled systems into analyzable units, letting agents map dependencies, propose changes, and verify behavior. This article examines the technique, its practical workflow, and where human review remains essential.

Audio reading is not available in this browser
Modernizing Complex Legacy Code with AI Agents: Mistral's Approach

Tags

Quick summary

Mistral has published research on using AI agents to modernize complex legacy codebases. Rather than treating migration as a single rewrite, the approach decomposes large, tangled systems into analyzable units, letting agents map dependencies, propose changes, and verify behavior. This article examines the technique, its practical workflow, and where human review remains essential.

Modernizing Complex Legacy Code with AI Agents: Mistral's Approach

A legacy system is rarely a single problem. It is a stack of them: a build that only succeeds on one engineer's laptop, business rules that exist only in production data, a test suite that documents intent from a decade ago, and a dependency graph nobody has fully mapped. Traditional modernization tooling — codemods, AST rewriters, scripted refactors — is excellent at the mechanical layer and fragile at the semantic one. The moment a rule is expressed through dynamic dispatch, a generated file, or a configuration flag, deterministic rewriting stops.

Mistral has published a piece on modernizing complex legacy code with AI agents at https://mistral.ai/news/legacy-code-modernization. That publication frames the problem the way this article does: modernization is a repository-scale task, not a completion task. What follows is a practical engineering treatment of that frame — how to set up an agent loop that actually lands changes in a legacy codebase, what to verify, and where the approach breaks.

A note on evidence: the only external source used here is Mistral's publication on the topic. Where the article describes tooling behavior, sandboxing, or test strategy, those are standard engineering practices and my own implementation guidance, not product claims. No model names, versions, or benchmarks are asserted, because the source does not support them and they change faster than any article can track.

What Makes Legacy Modernization Different

Greenfield agent workflows are forgiving. The code compiles, the tests are meaningful, and a wrong edit is caught quickly. Legacy work inverts all three assumptions.

Behavior is undocumented. The system works, which means the current behavior is the specification. Any refactor that changes observable output is a regression, even if the new output looks more reasonable.

The feedback signal is noisy. A build may take forty minutes, fail intermittently, or require a database snapshot to run at all. Agents optimize against feedback, so the quality of that feedback sets the ceiling on quality of results.

Scope is unbounded by default. "Modernize the billing module" can touch four hundred files or four. Without a mechanical definition of done, an agent will keep expanding until it runs out of context.

Blast radius is asymmetric. Deleting a function that appears unused can be catastrophic when the call site is resolved through reflection or a string in a database row.

Any serious approach has to answer these four constraints before it answers anything about model choice.

What Agents Add That Static Tooling Cannot

The meaningful difference between an agent and a code-completion model is the loop. An agent reads, edits, executes, observes the result, and revises. In legacy work, the executable environment — compiler, linter, test harness, type checker — becomes the ground truth that no amount of prompting can override.

Three capabilities matter specifically here:

  • Repository-scale context assembly. Agents can search, read, and cross-reference far more of the codebase than fits in a single prompt, building a working map before editing.
  • Tool-mediated verification. The agent's own confidence is irrelevant; the build result is not. Every proposed change can be gated on a command that returns an exit code.
  • Iterative repair. Legacy migrations produce long tails of small compile errors. A loop that fixes errors until the build is green offloads exactly the work humans find most tedious.

The practical consequence: you stop asking an agent to write a migration and start asking it to converge on one, under constraints you control.

Requirements

Before installing anything, confirm the following are in place. Each item corresponds to a failure mode that is expensive to discover mid-migration.

  • Version control with a clean working tree. Agents produce large diffs; without git you cannot inspect, bisect, or revert them.
  • A reproducible build entry point. A single command that builds the project from scratch and returns a non-zero exit code on failure.
  • A runnable test command. Even a thin suite is enough to start. If there is no suite, characterization tests come first (see the usage examples).
  • An isolated execution environment. A container or disposable VM. Never point an agent with filesystem and shell access at a machine holding production credentials.
  • A language toolchain for the target system — compiler, package manager, and any code generators the build depends on.
  • Python 3.10 or later for the harness described below.
  • An API credential for your model provider, stored as an environment variable rather than in a file that could be committed.
  • A budget and rate-limit plan. Repository-scale loops make many calls; a runaway agent can consume quota quickly.

Step-by-Step Installation

The setup below creates a small workspace that keeps the agent harness separate from the repository it operates on. Separation matters: you want the harness versioned and reviewable, and the target repository on its own branch.

1. Create the harness workspace

mkdir legacy-agent && cd legacy-agent
python3 -m venv .venv
source .venv/bin/activate

The first command creates a directory for your orchestration code, the second creates an isolated Python environment, and the third activates it so later installs do not touch the system interpreter.

2. Install the client and verification tooling

pip install --upgrade pip
pip install mistralai

This upgrades pip and installs the official Mistral Python client. Check your provider's current client documentation for the exact import and constructor signature — these evolve, and pinning a version in a requirements.txt is strongly recommended for anything you intend to run repeatedly.

pip install pytest pytest-cov ruff

These add a test runner, coverage measurement, and a fast linter. The linter matters more than usual in legacy work: it catches accidental deletions and syntax drift before the slow build runs.

3. Build a disposable sandbox

docker run --rm -it \
  -v "$PWD/../legacy-repo:/work" \
  -w /work \
  --network none \
  python:3.12-slim bash

This starts a throwaway container with the target repository mounted at /work and, critically, --network none to cut off outbound traffic. Mount only what the build needs. If the project requires network access to resolve dependencies, resolve them during image build and run the agent loop offline.

4. Configure credentials and paths

export MISTRAL_API_KEY="your-key-here"
export LEGACY_REPO="$HOME/src/legacy-repo"
export AGENT_MODEL="<model available in your account>"

The key is supplied through the environment so it never lands in a committed file. AGENT_MODEL is deliberately left unset to a specific value: model availability and naming change, so read it from configuration rather than hardcoding it in the harness.

5. Capture a baseline

cd "$LEGACY_REPO"
git checkout -b modernization/agent-work
./build.sh > ../baseline-build.log 2>&1; echo "exit=$?"
./test.sh  > ../baseline-test.log  2>&1; echo "exit=$?"

These commands create a dedicated branch and record the current build and test results. Without a baseline you cannot distinguish a regression introduced by the agent from a test that was already failing when you started. Record both exit codes and keep the logs.

Configuration: Making the Repository Legible

Agents fail on legacy code most often because the repository is illegible, not because the model is weak. Three configuration artifacts fix most of that.

The context file

Place a file named AGENTS.md at the repository root. It is read at the start of every task and should be short, factual, and boring.

# Repository context

Build:      ./build.sh          (expect exit 0)
Test:       ./test.sh           (expect exit 0)
Lint:       ruff check src/

## Rules
- Do not modify anything under tests/ or testdata/.
- Do not edit generated files (headers marked "DO NOT EDIT").
- Maximum diff size per task: 400 changed lines.
- If a symbol appears unused, report it. Do not delete it.
- Prefer adding an adapter over changing an existing public signature.

## Known hazards
- src/legacy/pricing.py resolves handlers by string name at runtime.
- The build requires JAVA_HOME to be set.
- Module `reporting` has no test coverage.

The prohibition on deleting apparently unused symbols is not paranoia. Dynamic resolution, reflection, and configuration-driven dispatch are exactly the patterns that make legacy systems resistant to static analysis.

The tool allowlist

Define, in a small config file, precisely which commands the agent may run. Everything else should be denied by default.

# agent-policy.yaml
tools:
  read_file: true
  write_file: true
  search: true
  shell:
    allow:
      - "./build.sh"
      - "./test.sh"
      - "ruff check"
      - "pytest"
    deny:
      - "git push"
      - "rm -rf"
      - "curl"
      - "pip install"
limits:
  max_iterations: 25
  max_files_changed: 15
  max_diff_lines: 400

The iteration and diff caps are the single most effective guardrail. They convert "the agent rewrote everything" into "the agent stopped and asked."

The ignore file

Add .agent-work/ to .gitignore so scratch inventories, logs, and intermediate reports never enter the diff under review.

A Minimal Agent Harness

The harness below is deliberately small. It exposes four tools, runs a bounded loop, and treats the build result as the only success signal. Provider-specific client code is isolated behind one adapter function so you can swap it without touching the loop.

import json, os, subprocess, pathlib

REPO = pathlib.Path(os.environ["LEGACY_REPO"])
POLICY = json.loads(pathlib.Path("agent-policy.yaml.json").read_text())

def run(cmd: str) -> dict:
    """Execute an allowlisted command and return its output and exit code."""
    if not any(cmd.startswith(a) for a in POLICY["tools"]["shell"]["allow"]):
        return {"error": f"command not allowlisted: {cmd}"}
    p = subprocess.run(cmd, shell=True, cwd=REPO,
                       capture_output=True, text=True, timeout=900)
    return {"exit": p.returncode, "stdout": p.stdout[-4000:], "stderr": p.stderr[-2000:]}

def read_file(path: str) -> str:
    return (REPO / path).read_text(errors="replace")[:20000]

def write_file(path: str, content: str) -> str:
    target = REPO / path
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(content)
    return f"wrote {len(content)} bytes to {path}"

def search(pattern: str) -> str:
    p = subprocess.run(["rg", "-n", "--max-count", "5", pattern, str(REPO)],
                       capture_output=True, text=True)
    return p.stdout[:8000]

def ask_model(messages: list) -> dict:
    """Adapter: return {'tool': name, 'args': {...}} or {'final': text}."""
    raise NotImplementedError("wire this to your provider's client")

TOOLS = {"read_file": read_file, "write_file": write_file,
         "search": search, "shell": run}

def run_agent(task: str) -> str:
    messages = [{"role": "user", "content": task}]
    for step in range(POLICY["limits"]["max_iterations"]):
        reply = ask_model(messages)
        if "final" in reply:
            return reply["final"]
        result = TOOLS[reply["tool"]](**reply["args"])
        messages.append({"role": "assistant", "content": json.dumps(reply)})
        messages.append({"role": "user", "content": json.dumps(result)})
        if reply["tool"] == "shell" and reply["args"]["cmd"] == "./build.sh" \
           and result.get("exit") == 0:
            messages.append({"role": "user",
                             "content": "Build is green. Run ./test.sh to confirm."})
    return "iteration limit reached — escalating to human review"

Two design choices are worth calling out. First, the loop never accepts the model's word for success; only a zero exit code from the build counts. Second, hitting the iteration limit is treated as a legitimate outcome that produces a report, not a failure to be retried indefinitely.

Usage Examples

Example 1 — Inventory before intervention

Never let an agent edit on the first pass. Start with a read-only mapping task.

Using search and read_file only, produce inventory.json containing:
- every top-level module and its file count
- the ten files with the highest inbound reference count
- modules with no corresponding test file
- any file containing the string "DO NOT EDIT"
Do not modify any file. Report your confidence per module.

Verify the result yourself with standard tooling before trusting it:

rg --files -g '*.py' | wc -l
rg -n "DO NOT EDIT" -l

The first command counts source files, the second lists generated files. Compare both against the agent's inventory; discrepancies are where your context file needs improving.

Example 2 — Characterization tests as a safety net

If the module you intend to change has no tests, build one around its current behavior before touching it. The point is not correctness — it is pinning today's output so tomorrow's refactor can be compared against it.

import subprocess, pytest

CASES = ["order-1001", "order-1002", "refund-partial", "currency-mixed"]

@pytest.mark.parametrize("case", CASES)
def test_current_output_is_preserved(case):
    result = subprocess.run(
        ["./legacy_cli", "--case", case],
        capture_output=True, text=True, check=True,
    )
    assert result.stdout == open(f"golden/{case}.txt").read()

Generate the golden/ files from the unmodified system, review them by hand once, then freeze them. Have the agent write the harness; you approve the golden outputs.

Example 3 — Incremental extraction with the strangler pattern

Decompose the migration so every task is independently revertible.

Task: extract the tax calculation from src/legacy/orders.py into
src/tax/calculator.py behind an adapter.

Definition of done:
- src/legacy/orders.py imports the new module
- the original function remains, delegating to the adapter
- ./build.sh exits 0
- ./test.sh exits 0 and coverage on src/tax/ does not decrease
- no file outside src/legacy/orders.py and src/tax/ is modified
- diff is under 400 lines

If any constraint cannot be met, stop and report why.

Run the verification yourself rather than accepting the agent's summary:

git diff --stat
git diff --name-only | grep -v -E '^(src/legacy/orders.py|src/tax/)' && echo "SCOPE VIOLATION"
./build.sh && ./test.sh

The middle command is the important one. It fails loudly if the agent touched files outside the agreed scope, which is the most common form of silent drift.

Example 4 — Dependency upgrade driven by compiler feedback

Compiler errors are the cheapest feedback an agent can get. Frame the task as convergence, not authorship.

Upgrade the pinned version of <dependency> in requirements.txt to the
next major version. Do not change application logic.

Loop: edit, run ./build.sh, read the errors, fix only what the errors
require. After the build is green, run ./test.sh. If a test fails,
revert the change that caused it and report the failure instead of
adjusting the test.

The instruction not to adjust tests is essential. An agent optimizing for a green suite will happily rewrite the assertion, which converts a real regression into a passing build.

Guardrails and Failure Modes

The failure modes are consistent enough to be planned for:

  • Test tampering. The agent edits the test to match the new output. Mitigation: forbid writes under tests/, and review any diff touching assertions as a suspected regression.
  • Scope creep. A small task becomes a large one. Mitigation: hard file and line caps, enforced outside the agent.
  • Confident fabrication. The agent describes behavior it inferred rather than observed. Mitigation: require a command and its output as evidence for every behavioral claim.
  • Deletion of dynamically referenced code. Mitigation: never allow deletion in the same task as extraction; require a separate, human-approved pass.
  • Secret exposure. Mitigation: offline sandbox, environment-injected credentials, and a scan of every diff for key-like strings before merge.

Treat all agent output as a pull request from an unfamiliar contributor: useful, plausibly correct, and requiring review.

Measuring Whether It Works

Track a small set of numbers per migration, not per task:

  • Build-green rate after the first agent attempt.
  • Median diff size, and the count of scope violations.
  • Coverage on touched modules before and after.
  • Revert rate — changes merged and later reverted.
  • Human review minutes per merged change.

The last one is the honest metric. An agent that produces impressive diffs humans must scrutinize for an hour each has not improved anything.

What Mistral's Publication Signals

Mistral's piece positions legacy modernization as a task suited to agents rather than to autocomplete. That framing is consistent with everything above: the value comes from a bounded, verifiable loop operating on a repository, not from a single clever generation. Beyond that framing, this article makes no claims about specific products, capabilities, or performance, because the source does not support them and the details are moving targets. The engineering practices here — baselines, sandboxes, characterization tests, diff caps, human review — hold regardless of which model or vendor sits behind the adapter.

Conclusion

Modernizing complex legacy code with AI agents is not a matter of finding a better prompt. It is a matter of constructing an environment where the agent's guesses are cheap and its mistakes are caught by a compiler or a golden-master test before a human ever reads the diff.

The sequence that works is unglamorous. Establish a reproducible build and a runnable test command. Map the repository read-only before allowing edits. Pin current behavior with characterization tests. Extract in small, revertible slices with hard scope caps. Gate every merge on commands you run yourself. Keep a human in the loop for deletions, signature changes, and anything touching tests.

Do that, and the agent handles the long tail of mechanical convergence that makes legacy migration slow — while you keep control of the decisions that make it dangerous.

Sources