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:
- Detect — scan the project for outdated or vulnerable dependencies.
- Research — fetch changelogs, release notes, and migration guides.
- Analyze — use the LLM to assess impact against the actual codebase.
- Act — apply version bumps and code patches.
- Validate — run tests, linters, and type checks.
- Report — summarize results and open a pull request if appropriate.
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:
- Security — vulnerable packages are patched within hours rather than quarters.
- Reduced toil — engineers stop spending Fridays manually resolving
requirements.txtconflicts. - Smaller diffs — frequent, incremental upgrades are easier to review than monolithic migrations.
- Knowledge capture — the agent's reasoning is logged, creating an audit trail of why each change was made.
- Consistency — every upgrade follows the same validation pipeline, eliminating human variability.
Architecture Overview
Before writing code, it helps to define the components and their boundaries. A robust agent is composed of several loosely coupled modules:
- Package Scanner — wraps tools like
pip,npm, orcargoto list installed and available versions. - Changelog Fetcher — retrieves release notes from GitHub, PyPI, or package registries.
- Codebase Indexer — builds a searchable index of how each dependency is used in the project.
- LLM Planner — the reasoning core that decides what to upgrade and how.
- Patch Applier — modifies source files and manifest files.
- Test Runner — executes the project's test suite and parses results.
- PR Orchestrator — commits changes to a branch and opens a pull request.
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.