← Back to DevBytes

Building an AI Agent for Dependency Upgrades

Building an AI Agent for Dependency Upgrades

Keeping dependencies up to date is one of the most repetitive and error-prone tasks in software engineering. Every project accumulates dozens — often hundreds — of third-party packages, each with its own release cadence, changelog, and breaking changes. Manually triaging these updates is tedious, and ignoring them invites security vulnerabilities and compatibility drift. An AI agent for dependency upgrades automates this workflow: it detects outdated packages, reads release notes, evaluates compatibility with your codebase, drafts the necessary code changes, and validates them with your test suite. This tutorial walks through building such an agent from scratch.

What Is an AI Agent for Dependency Upgrades?

An AI agent for dependency upgrades is an autonomous system that combines package management tooling with a large language model (LLM) to perform upgrade-related reasoning and actions. Unlike a simple bot that bumps version numbers, the agent understands the semantic impact of an upgrade. It can read a changelog, identify whether a breaking change affects your code, propose patches, run tests, and decide whether to merge or roll back.

Conceptually, the agent operates in a loop:

Why It Matters

Dependency maintenance is a tax every team pays. Left unmanaged, it compounds into technical debt: security advisories pile up, upgrades become multi-week projects, and teams fall further behind. An AI agent shifts this work from reactive firefighting to continuous, low-friction maintenance. The benefits are concrete:

Architecture Overview

Before writing code, it helps to define the components and their boundaries. A robust agent is composed of several loosely coupled modules:

The agent loop ties these together. Each iteration produces a structured result that feeds the next decision. We will implement this in Python, but the pattern transfers to any language.

Setting Up the Project

Create a new directory and install the dependencies we will use:

mkdir dep-agent && cd dep-agent
python -m venv .venv
source .venv/bin/activate
pip install openai pydantic requests gitpython pytest

We use openai for LLM calls, pydantic for structured outputs, requests for fetching changelogs, gitpython for branch management, and pytest for running the target project's tests. The agent itself is framework-agnostic; you could swap OpenAI for Anthropic or a local model with minimal changes.

Building the Package Scanner

The scanner is the agent's eyes. It reads the project's dependency manifest and compares installed versions against the latest available releases. We will target Python projects using requirements.txt, but the same approach works for pyproject.toml, package.json, or Cargo.toml.

# scanner.py
import re
import subprocess
from dataclasses import dataclass
from typing import List

@dataclass
class Dependency:
    name: str
    current_version: str
    latest_version: str
    is_outdated: bool

VERSION_PATTERN = re.compile(r"^([a-zA-Z0-9_.\-]+)==([0-9a-zA-Z.\-+]+)$")

def parse_requirements(path: str = "requirements.txt") -> List[tuple]:
    deps = []
    with open(path, "r") as f:
        for line in f:
            line = line.strip()
            match = VERSION_PATTERN.match(line)
            if match:
                deps.append((match.group(1), match.group(2)))
    return deps

def get_latest_version(package: str) -> str:
    result = subprocess.run(
        ["pip", "index", "versions", package],
        capture_output=True, text=True
    )
    # Output looks like: "Available versions: 2.1.0, 2.0.0, 1.9.0"
    versions_line = result.stdout
    match = re.search(r"Available versions: ([0-9a-zA-Z.,\-+ ]+)", versions_line)
    if match:
        versions = [v.strip() for v in match.group(1).split(",")]
        return versions[0]  # first is latest
    return ""

def scan_dependencies(path: str = "requirements.txt") -> List[Dependency]:
    parsed = parse_requirements(path)
    results = []
    for name, current in parsed:
        latest = get_latest_version(name)
        results.append(Dependency(
            name=name,
            current_version=current,
            latest_version=latest,
            is_outdated=(latest != "" and latest != current),
        ))
    return results

This scanner is intentionally simple. In production you would add caching to avoid repeated registry lookups, handle pinned ranges (>=, ~), and support lockfiles. The key design principle is that the scanner returns structured data the rest of the agent can reason about.

Fetching Changelogs

Version numbers alone do not tell the agent whether an upgrade is safe. The agent needs the human-readable context that changelogs provide. Most Python packages host their source on GitHub, and PyPI exposes repository metadata we can use to locate release notes.

# changelog.py
import requests
from typing import Optional

PYPI_API = "https://pypi.org/pypi/{package}/json"
GITHUB_RELEASES = "https://api.github.com/repos/{owner}/{repo}/releases"

def get_pypi_metadata(package: str) -> dict:
    resp = requests.get(PYPI_API.format(package=package), timeout=10)
    resp.raise_for_status()
    return resp.json()

def get_changelog(package: str, version: str) -> Optional[str]:
    metadata = get_pypi_metadata(package)
    info = metadata.get("info", {})
    project_urls = info.get("project_urls", {}) or {}

    # Try to find a GitHub repository URL
    repo_url = None
    for label, url in project_urls.items():
        if "github.com" in url.lower():
            repo_url = url
            break

    if not repo_url:
        return info.get("description", "")[:2000]

    # Extract owner/repo from URL
    parts = repo_url.rstrip("/").split("/")
    owner, repo = parts[-2], parts[-1]

    resp = requests.get(
        GITHUB_RELEASES.format(owner=owner, repo=repo),
        timeout=10,
        headers={"Accept": "application/vnd.github+json"},
    )
    if resp.status_code != 200:
        return None

    releases = resp.json()
    for release in releases:
        if release.get("tag_name", "").lstrip("v") == version:
            return release.get("body", "")

    # Fallback: return the latest release notes
    if releases:
        return releases[0].get("body", "")
    return None

This fetcher tries GitHub releases first because they tend to be structured and concise. If no GitHub link exists, it falls back to the PyPI long description. You can extend this to parse CHANGELOG.md files directly from the repository or to query GitLab and Bitbucket.

Indexing Codebase Usage

To assess impact, the agent must know where and how a dependency is used. A lightweight approach is to grep for import statements and collect the surrounding context. For larger projects, you might integrate a code search tool or an embedding-based retriever, but a regex-based indexer is sufficient for most cases.

# indexer.py
import os
import re
from dataclasses import dataclass
from typing import List

@dataclass
class UsageSite:
    file: str
    line_number: int
    line: str
    context: str  # a few surrounding lines

IMPORT_PATTERN = re.compile(
    r"^\s*(import|from)\s+([a-zA-Z0-9_.]+)", re.MULTILINE
)

def index_usage(package: str, root: str = ".") -> List[UsageSite]:
    sites = []
    for dirpath, _, filenames in os.walk(root):
        if any(skip in dirpath for skip in [".venv", "node_modules", ".git", "__pycache__"]):
            continue
        for fname in filenames:
            if not fname.endswith(".py"):
                continue
            fpath = os.path.join(dirpath, fname)
            with open(fpath, "r", errors="ignore") as f:
                lines = f.readlines()
            for i, line in enumerate(lines):
                if re.search(rf"\b{re.escape(package)}\b", line):
                    start = max(0, i - 2)
                    end = min(len(lines), i + 3)
                    context = "".join(lines[start:end])
                    sites.append(UsageSite(
                        file=fpath,
                        line_number=i + 1,
                        line=line.rstrip(),
                        context=context,
                    ))
    return sites

The indexer returns concrete usage sites with context. When the LLM later evaluates a breaking change, it can look at the actual code that would be affected rather than guessing.

The LLM Planner

The planner is the brain of the agent. It receives the dependency information, the changelog, and the usage sites, then decides whether the upgrade is safe and what code changes are needed. We use OpenAI's structured output feature to get a predictable response format.

# planner.py
import json
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import List, Optional

client = OpenAI()

class CodeChange(BaseModel):
    file: str = Field(description="Path to the file to modify")
    old_code: str = Field(description="The exact code to replace")
    new_code: str = Field(description="The replacement code")

class UpgradePlan(BaseModel):
    should_upgrade: bool = Field(description="Whether the upgrade is safe to apply")
    risk_level: str = Field(description="low, medium, or high")
    summary: str = Field(description="One-paragraph explanation of the decision")
    breaking_changes: List[str] = Field(default_factory=list)
    code_changes: List[CodeChange] = Field(default_factory=list)

def build_prompt(dep, changelog, usage_sites):
    usage_text = ""
    for site in usage_sites[:15]:  # cap context length
        usage_text += f"\n--- {site.file}:{site.line_number} ---\n{site.context}\n"

    return f"""You are a dependency upgrade agent. Analyze whether upgrading the
Python package "{dep.name}" from version {dep.current_version} to
{dep.latest_version} is safe for this codebase.

CHANGELOG / RELEASE NOTES:
{changelog or 'No changelog available.'}

CODEBASE USAGE SITES:
{usage_text or 'Package not directly imported in scanned files.'}

Decide whether to upgrade. If there are breaking changes that affect the
usage sites above, propose specific code changes. Be conservative: if you
cannot verify safety, set should_upgrade to false."""

def plan_upgrade(dep, changelog, usage_sites) -> UpgradePlan:
    prompt = build_prompt(dep, changelog, usage_sites)
    response = client.beta.chat.completions.parse(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        response_format=UpgradePlan,
        temperature=0,
    )
    return response.choices[0].message.parsed

The prompt is deliberately structured. It gives the model the changelog, the actual code that uses the package, and asks for a conservative judgment. The temperature=0 setting reduces randomness, which matters when the agent is making decisions about production code.

Applying Patches

When the planner returns code changes, the agent applies them to the working tree. The applier must be careful to match the exact text the LLM specified and to fail loudly if the match is not found, rather than silently corrupting files.

# applier.py
import os
from typing import List
from planner import CodeChange

def apply_changes(changes: List[CodeChange]) -> List[str]:
    applied = []
    for change in changes:
        if not os.path.exists(change.file):
            raise FileNotFoundError(f"Target file not found: {change.file}")

        with open(change.file, "r") as f:
            content = f.read()

        if change.old_code not in content:
            raise ValueError(
                f"Could not find expected code block in {change.file}. "
                "The LLM may have hallucinated the snippet."
            )

        new_content = content.replace(change.old_code, change.new_code, 1)
        with open(change.file, "w") as f:
            f.write(new_content)

        applied.append(change.file)
    return applied

def update_requirements(path: str, name: str, new_version: str):
    with open(path, "r") as f:
        lines = f.readlines()

    updated = False
    for i, line in enumerate(lines):
        if line.strip().startswith(f"{name}=="):
            lines[i] = f"{name}=={new_version}\n"
            updated = True
            break

    if not updated:
        lines.append(f"{name}=={new_version}\n")

    with open(path, "w") as f:
        f.writelines(lines)

The replace(..., 1) argument ensures only the first occurrence is replaced, preventing unintended edits if the same snippet appears multiple times. The explicit ValueError on mismatch is a guardrail against LLM hallucination — a critical concern when an agent modifies source files.

Running Tests

Validation is what separates an agent from a script that blindly edits files. After applying changes, the agent runs the project's test suite and interprets the results. If tests fail, the agent should roll back.

# validator.py
import subprocess
from dataclasses import dataclass

@dataclass
class TestResult:
    passed: bool
    output: str
    failed_tests: List[str]

def run_tests(test_command: List[str] = None) -> TestResult:
    if test_command is None:
        test_command = ["python", "-m", "pytest", "-v", "--tb=short"]

    result = subprocess.run(
        test_command,
        capture_output=True,
        text=True,
        timeout=300,
    )

    output = result.stdout + "\n" + result.stderr
    failed = []
    for line in output.splitlines():
        if line.strip().startswith("FAILED"):
            failed.append(line.strip())

    return TestResult(
        passed=(result.returncode == 0),
        output=output,
        failed_tests=failed,
    )

If tests fail, the agent can optionally feed the failure output back to the LLM for a second attempt at fixing the code. This creates a self-healing loop, but you should cap the number of retries to avoid infinite cycles.

The Agent Loop

Now we assemble the pieces into the main agent loop. This is the orchestrator that ties detection, research, planning, action, and validation together.

# agent.py
from scanner import scan_dependencies
from changelog import get_changelog
from indexer import index_usage
from planner import plan_upgrade
from applier import apply_changes, update_requirements
from validator import run_tests
import git
import json

REPO_PATH = "."
REQUIREMENTS = "requirements.txt"
MAX_FIX_RETRIES = 2

def create_branch(repo, branch_name: str):
    repo.git.checkout("-b", branch_name)

def commit(repo, message: str):
    repo.git.add(A=True)
    repo.git.commit("-m", message)

def rollback(repo):
    repo.git.checkout("--", ".")
    repo.git.reset("--hard", "HEAD")

def run_agent():
    repo = git.Repo(REPO_PATH)
    deps = scan_dependencies(REQUIREMENTS)
    outdated = [d for d in deps if d.is_outdated]

    print(f"Found {len(outdated)} outdated dependencies.")

    for dep in outdated:
        print(f"\n=== Processing {dep.name}: {dep.current_version} -> {dep.latest_version} ===")
        changelog = get_changelog(dep.name, dep.latest_version)
        usage_sites = index_usage(dep.name, REPO_PATH)
        plan = plan_upgrade(dep, changelog, usage_sites)

        print(f"Risk: {plan.risk_level}")
        print(f"Decision: {'UPGRADE' if plan.should_upgrade else 'SKIP'}")
        print(f"Summary: {plan.summary}")

        if not plan.should_upgrade:
            continue

        branch_name = f"upgrade/{dep.name}-{dep.latest_version}"
        create_branch(repo, branch_name)

        try:
            if plan.code_changes:
                apply_changes(plan.code_changes)
            update_requirements(REQUIREMENTS, dep.name, dep.latest_version)

            # Validate with retries
            for attempt in range(MAX_FIX_RETRIES + 1):
                result = run_tests()
                if result.passed:
                    print("Tests passed.")
                    break
                print(f"Tests failed (attempt {attempt + 1}): {result.failed_tests}")
                if attempt < MAX_FIX_RETRIES:
                    # Feed failures back to the LLM for a fix attempt
                    fix_plan = plan_upgrade(dep, result.output, usage_sites)
                    if fix_plan.code_changes:
                        apply_changes(fix_plan.code_changes)
                else:
                    print("Max retries reached. Rolling back.")
                    rollback(repo)
                    continue

            commit(repo, f"Upgrade {dep.name} to {dep.latest_version}")
            print(f"Committed on branch {branch_name}")

        except Exception as e:
            print(f"Error during upgrade: {e}. Rolling back.")
            rollback(repo)

if __name__ == "__main__":
    run_agent()

This loop processes one dependency at a time, each on its own branch. The isolation matters: if one upgrade fails, it does not contaminate others. The retry loop gives the agent a chance to fix test failures before giving up.

How to Use the Agent

To run the agent against a project, navigate to the project directory and execute the agent script. Ensure the project has a requirements.txt and a test suite that the agent can run.

cd /path/to/your/project
export OPENAI_API_KEY="sk-..."
python /path/to/dep-agent/agent.py

The agent will scan dependencies, create a branch for each upgrade, apply changes, run tests, and commit. You can then review the branches and merge them individually. For CI integration, wrap the agent in a GitHub Action that runs on a schedule:

# .github/workflows/dep-upgrades.yml
name: Dependency Upgrades
on:
  schedule:
    - cron: "0 6 * * 1"  # every Monday at 6 AM
  workflow_dispatch:

jobs:
  upgrade:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: pip install openai pydantic requests gitpython
      - run: python agent.py
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      - name: Create Pull Requests
        run: |
          for branch in $(git branch --list 'upgrade/*'); do
            gh pr create --base main --head "$branch" \
              --title "Auto: $branch" --body "Automated dependency upgrade"
          done
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

This runs the agent weekly and opens a pull request for each successful upgrade. Reviewers see the agent's summary, the diff, and the green test status before merging.

Best Practices

Always Validate Before Committing

Never let the agent commit changes that have not passed the test suite. Tests are the ground truth. If a project has low test coverage, the agent's confidence should be correspondingly lower, and you may want to require manual review for high-risk upgrades.

Cap LLM Autonomy

The agent should operate within guardrails. Limit the number of files it can modify per run, cap retry loops, and restrict it to dependencies below a certain risk threshold. For major version bumps (e.g., 1.x to 2.x), consider requiring human approval even if tests pass.

Log Every Decision

Persist the planner's output — the risk assessment, the summary, and the list of breaking changes — alongside the commit or pull request. This creates an audit trail and helps reviewers understand why the agent made each decision. A simple approach is to write the plan to a JSON file and include it in the PR body.

Use Deterministic Tools First

Before invoking the LLM, run deterministic checks: vulnerability scanners like pip-audit or safety, compatibility linters, and type checkers. The LLM should complement these tools, not replace them. Deterministic tools catch obvious issues cheaply; the LLM handles nuanced reasoning that rules cannot express.

Handle Lockfiles Carefully

If your project uses lockfiles (poetry.lock, package-lock.json), the agent must regenerate them after bumping versions. Forgetting this step leads to inconsistent environments. Add a lockfile regeneration step to the applier module.

Start Small and Expand

Begin by running the agent on patch and minor version upgrades only. Once you trust its behavior, enable major version upgrades with additional review gates. This incremental rollout builds confidence and surfaces edge cases early.

Monitor Costs

LLM calls are not free. Each dependency scan that includes changelog analysis and usage indexing consumes tokens. Cache changelogs, limit context length, and batch low-risk upgrades to reduce per-package cost. Track spending and set budget alerts.

Conclusion

Building an AI agent for dependency upgrades is a practical way to eliminate one of the most persistent forms of engineering toil. By combining deterministic package tooling with LLM-based reasoning, you get a system that not only bumps versions but understands the implications of each change. The architecture described here — scanner, changelog fetcher, indexer, planner, applier, validator — is modular by design, so you can extend it to additional ecosystems, add vulnerability prioritization, or integrate deeper code analysis. Start with a narrow scope, validate rigorously, and let the agent earn trust gradually. Done well, it transforms dependency maintenance from a quarterly crisis into a continuous, background process that keeps your codebase secure and current with minimal human intervention.

— Ad —

Google AdSense will appear here after approval

← Back to all articles