DEV Community

Cover image for The tutorials moved faster than I did: building an agent that catches deprecated dependencies
joshyfruit
joshyfruit

Posted on

The tutorials moved faster than I did: building an agent that catches deprecated dependencies

I set out to learn Amazon Bedrock AgentCore, AWS’s managed runtime for AI agents. So the plan is to find a few tutorials, follow one, and deploy something.

Instead, I found the project I ended up building.

Several walkthroughs teach bedrock-agentcore-starter-toolkit. The package now describes itself this way:

Python CLI toolkit for Amazon Bedrock AgentCore (legacy). For new projects, use the AgentCore CLI.

No one did anything wrong. AWS released a replacement, @aws/agentcore, and clearly marked the old package as legacy. The walkthroughs were accurate when their authors published them. The tooling progressed more quickly, which happens often in this ecosystem.

Some current AWS guides teach the new CLI. The stale material includes the old toolkit’s own docs site, which is still online and easy to reach, plus third-party posts. No single source is wrong, so there is no single fix. You have to check what you are installing.

So I found a use case. strands-agents/sdk-python, the agent framework’s repository, had been renamed to strands-agents/harness-sdk. hmmmm! 🤔.

You can see the redirect here:

Terminal showing a curl request to the GitHub API for strands-agents/sdk-python returning HTTP status 301

The response is 301 Moved Permanently. Follow it, and you land on harness-sdk. Nothing breaks. Old links still resolve because that is what a 301 is for. pip install strands-agents also works because the package name did not change, only the repository did. The PyPI listing already points to the new URL. GitHub and PyPI are both doing the helpful thing.

That is why the rename is easy to miss. Every signal you would normally check looks fine.

I caught both cases only because I checked the repositories instead of trusting what I had read. If I had followed a tutorial as written, I would have installed a legacy CLI and spent an hour wondering why a command didn't exist. That gave me something worth building.

The gap this sits in

Tools already exist for outdated dependencies: Dependabot, Renovate, npm outdated, and pip list --outdated. They compare the version you pinned with the latest version, then report the difference.

That works well for version gaps, but it misses three other problems:

What happened What the version number shows
The repo was renamed Nothing. Your old name still resolves.
The project was archived Nothing. The last version is still the latest version.
The notes say “deprecated, migrate to X” Nothing. That’s prose, in a release body.

Number comparison cannot detect any of these. The evidence is written in plain English on the repository page. In the third case, someone has written a paragraph asking you to stop using the dependency.

Number comparison cannot read. An agent can. “Read this page and tell me if it says anything alarming” is a good task for a language model and a poor one for a regex.

I called the project Release Radar.

What it does

You give it a list of pinned dependencies. For each one, it finds the repository, reads its description and latest release notes, checks the version distance, and returns one of three verdicts:

  • BLOCKED: renamed, archived, or described as deprecated in the repository description or release notes
  • BEHIND: a real version gap
  • OK: current or only trivially behind

That is the whole product. I kept it small enough to finish in an afternoon, but useful enough to catch a real problem.

The stack

Two parts of the stack are easy to confuse:

  • Strands Agents is the framework, including the loop that decides which tool to use next. It runs anywhere, including your laptop.
  • Bedrock AgentCore is AWS’s managed runtime. It is where the agent lives when you want it to run on a schedule instead of only when you remember to start it.

Just to let you know, this is a 2-part series;
1️⃣ This activity (this post you are reading) builds the agent and checks its deterministic function logic locally.
2️⃣ The next post moves the complete agent into AgentCore.

First, remove the conflicting CLI:

pip uninstall bedrock-agentcore-starter-toolkit
# Or depending on how you installed it: 
# pipx uninstall
# uv tool uninstall

npm install -g @aws/agentcore #install agentcore

which -a agentcore  # Must print exactly ONE path.
Enter fullscreen mode Exit fullscreen mode

Do not skip which -a. If it prints two paths, the old CLI is shadowing the new one. Every confusing error that follows will trace back to that conflict.

Next, scaffold the project. The wizard asks for a framework and a model provider. Choose Strands Agents and Amazon Bedrock. Bedrock is the only provider in the wizard that does not require an API key because it uses your existing AWS credentials. Anthropic, Google Gemini, and OpenAI each require a key.

agentcore create  # Name it: release-radar
cd release-radar
Enter fullscreen mode Exit fullscreen mode

The command creates two directories: configuration in agentcore/ and your code in app/. You only need to edit app/.

Writing the tools

A Strands tool is a regular Python function with a decorator. Its docstring is routing logic, not ordinary documentation.

The model reads the docstring to decide whether to call the function. If it is vague, the tool may never run, leaving you with a correct-looking agent that ignores half its capabilities. Write docstrings for the model rather than for a future maintainer.

The second paragraph below does most of the routing work:

import json
import os
import urllib.error
import urllib.request

from strands import tool

API = "https://api.github.com"


def _get(path: str) -> dict:
    """GET a GitHub API path. Never raises—errors come back as data."""
    req = urllib.request.Request(
        API + path,
        headers={
            "Accept": "application/vnd.github+json",
            "User-Agent": "release-radar",
        },
    )

    # Lifts the anonymous rate limit from 60/hr to 5000/hr.
    if token := os.environ.get("GITHUB_TOKEN"):
        req.add_header("Authorization", f"Bearer {token}")

    try:
        with urllib.request.urlopen(req, timeout=10) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        return {"_error": f"HTTP {e.code}"}
    except Exception as e:
        return {"_error": str(e)[:120]}


@tool
def repo_status(owner: str, repo: str) -> dict:
    """Look up a GitHub repository's current identity and health.

    Returns the canonical full_name, which differs from the requested
    owner/repo when the project has been renamed. Also reports whether
    the repo is archived and when it was last pushed to. Call this
    FIRST for every dependency—a rename or archive matters more than
    any version gap.
    """
    asked = f"{owner}/{repo}"
    d = _get(f"/repos/{owner}/{repo}")
    if "_error" in d:
        return {"requested": asked, "error": d["_error"]}

    return {
        "requested": asked,
        "canonical": d["full_name"],
        "renamed": d["full_name"].lower() != asked.lower(),
        "archived": d["archived"],
        "description": (d["description"] or "")[:280],
        "last_push": d["pushed_at"],
        "stars": d["stargazers_count"],
    }
Enter fullscreen mode Exit fullscreen mode

_get never raises. It returns errors as {"_error": ...} data. When a tool throws, it kills the agent’s turn. When it returns an error string, the model can say “that one 404’d” and continue. The failure becomes data instead of control flow.

So the rename check is one line: d["full_name"].lower() != asked.lower(). GitHub’s API follows the redirect and returns the canonical name. Ask for strands-agents/sdk-python, and it returns strands-agents/harness-sdk. Comparing the two reveals the rename. That one line is why this project exists.

The second tool fetches release notes. Together with the description returned by repo_status, this gives the model prose to inspect for words such as deprecated, legacy, superseded, and migrate:

@tool
def latest_release(owner: str, repo: str) -> dict:
    """Fetch the most recent published release for a repository.

    Returns the tag name, publish date, and the opening of the release
    notes. Read the notes for deprecation and breaking-change language.
    """
    d = _get(f"/repos/{owner}/{repo}/releases/latest")
    if "_error" in d:
        return {
            "repo": f"{owner}/{repo}",
            "error": d["_error"],
            "hint": "404 here usually means the repo publishes tags, not releases",
        }

    return {
        "repo": f"{owner}/{repo}",
        "tag": d["tag_name"],
        "published": d["published_at"],
        "notes": (d.get("body") or "")[:600],
    }
Enter fullscreen mode Exit fullscreen mode

This field hint is intentional because the model reads the error when the tool fails, so the error should explain what probably happened. Many repositories publish tags without releases. A bare 404 would leave the agent guessing.

Limitation

One limitation, GitHub defines “latest” at the repository level. In a monorepo with separate Python and TypeScript release streams, this endpoint may return the newest release for the wrong language. That does not affect a rename or archive verdict, but production code should filter releases by the dependency’s tag prefix.

The bug I shipped

The third tool compares versions. Most of the code is parsing because version tags come in forms such as 1.2.3, v1.2.3, and, in Strands’ case, python/v1.54.0:

@tool
def version_gap(pinned: str, latest: str) -> dict:
    """Compare a pinned version against the latest release tag.

    Handles common prefixes (v1.2.3, python/v1.2.3) and returns how many
    major, minor, and patch releases the pin is behind.
    """

    def parts(v: str) -> tuple:
        tail = v.strip().rsplit("/", 1)[-1].lstrip("vV")
        core = tail.split("-")[0].split("+")[0]
        out = []
        for chunk in core.split(".")[:3]:
            digits = "".join(c for c in chunk if c.isdigit())
            out.append(int(digits) if digits else 0)
        while len(out) < 3:
            out.append(0)
        return tuple(out)

    p, l = parts(pinned), parts(latest)
    level = ("major", "minor", "patch")
    behind = None
    for i in range(3):
        if l[i] != p[i]:
            behind = level[i] if l[i] > p[i] else None
            break

    return {
        "pinned": pinned,
        "latest": latest,
        "behind_by": behind,
        "current": p >= l,
    }
Enter fullscreen mode Exit fullscreen mode

My first attempt at the comparison loop was wrong. Because this function contains the project’s only substantial logic, I added asserts:

if __name__ == "__main__":
    assert version_gap("1.0.0", "1.0.0")["current"] is True
    assert version_gap("v0.1.0", "v0.28.1")["behind_by"] == "minor"
    assert version_gap("1.2.3", "2.0.0")["behind_by"] == "major"
    assert version_gap("python/v1.53.0", "python/v1.54.0")["behind_by"] == "minor"
    assert version_gap("2.0.0", "1.9.9")["behind_by"] is None
    print("version_gap ok")
Enter fullscreen mode Exit fullscreen mode

The last assert failed.

My first version found the earliest position where the latest version exceeded the pinned version:

behind = next((level[i] for i in range(3) if l[i] > p[i]), None)
Enter fullscreen mode Exit fullscreen mode

Try that with 2.0.0 and 1.9.9. At the major position, 1 > 2 is false. At the minor position, 9 > 0 is true. The function therefore reports “minor behind” even though the dependency is a full major version ahead.

The code scans all three positions independently, but version comparison must move from left to right and stop at the first difference. Later positions no longer matter. The corrected for loop does that with a break.

I could have written the one-liner, decided it looked right, and shipped it. You would only discover the bug when the agent confidently told you to upgrade a dependency you had already upgraded. At that point, you might blame the model instead of the arithmetic.

Five asserts caught the bug in thirty seconds. The deterministic parts of an agent still need tests. A model in the loop makes some behavior fuzzy, but version_gap is arithmetic, and arithmetic is easy to check.

Run the checks before continuing:

uv run python tools.py  # → version_gap ok
Enter fullscreen mode Exit fullscreen mode

Wiring the agent

from strands import Agent

from tools import latest_release, repo_status, version_gap

SYSTEM = """You audit pinned software dependencies.

For each `owner/repo@version` the user gives you:

 1. Call repo_status first. If `renamed` is true or `archived` is
    true, that is the headline; report it before anything else.
 2. Read the repository description and latest release notes for
    deprecated, legacy, superseded, migrate, or breaking.
 3. Call version_gap to measure the distance.

Then assign exactly one verdict per dependency:

 BLOCKED  renamed, archived, or description/notes say deprecated/legacy
 BEHIND   major or minor releases behind
 OK       current, or patch-behind only

Output one line per dependency: VERDICT  owner/repo  one-clause reason.

No preamble. If a tool returns an error, say so and move on."""

agent = Agent(
    system_prompt=SYSTEM,
    tools=[repo_status, latest_release, version_gap],
)
Enter fullscreen mode Exit fullscreen mode

The prompt sets the tool order, verdict vocabulary, and output format. This will leave those choices open, and the model tends to return three paragraphs of friendly hedging. A line such as BLOCKED aws/foo - archived is easy to grep and act on later.

agentcore create will generate a richer main.py than the version above. It will include session caching, MCP wiring, and a two-argument invoke(payload, context) entry point imported from bedrock_agentcore.runtime. The shorter version is easier to learn from, but in practice you should modify the generated file rather than replace it. Add tools.py, add one import, swap the system prompt, and replace the demo tool. That is three edits.

Point it at the real thing

Now test the three dependencies from the start of this post:

Audit these:

aws/bedrock-agentcore-starter-toolkit@0.1.0
strands-agents/sdk-python@1.0.0
aws/agentcore-cli@0.28.1
Enter fullscreen mode Exit fullscreen mode

The last pin was current when I wrote this. Run npm view @aws/agentcore version to get today’s version. If it has moved, you will get a live BEHIND result alongside the two BLOCKED results.

This is what the agent returned:

Terminal output showing two BLOCKED verdicts for the legacy starter toolkit and the renamed Strands repo, and one OK for the current CLI

The response may vary from time to time since LLMs are non-deterministic in nature. Across three runs, the third result said “pinned version is current,” “pinned version matches latest release,” and “current at v0.28.1.” What matters is that the first two results are BLOCKED and their reasons identify the rename and legacy status. If either comes back OK, repo_status is probably not being called. Check that its docstring survived the copy.

The agent finds the same problem that prompted me to build it. These are two live repositories, not synthetic fixtures, and either could cost someone an afternoon.

That is my standard for a tutorial project. It should do more than return “hello” from a hello-world fixture. It should fail informatively when pointed at reality.

At this point

The tools and agent wiring are ready. My 2nd post will cover deploying our Agent to AgentCore and will cover the issues that appear after it leaves your computer 🖥️:

  • The 250MB packaging fork that determines whether your deployment loop takes 40 seconds or 6 minutes
  • The trust boundary at the entry-point, and why validating input matters when the agent reads text written by strangers.
  • Reading traces to distinguish a correct answer from a lucky one
  • The failure table and teardown (We don't want that extra cost)

I have run these examples against the live GitHub API, npm, and PyPI. I also deployed the agent to a real AgentCore runtime, invoked it three times, and tore it down. The screenshot above is output from that deployed runtime, not a mocked fixture.

This post is a snapshot, and snapshots decay. If you are reading it much later, run the agent against its own examples. If strands-agents/sdk-python no longer returns a 301, something moved again. That is more useful to know than whether this post still looks trustworthy.

Here’s my GitHub repo for the code used in this project.

Top comments (0)