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:
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.
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.
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:
That is the whole product. I kept it small enough to finish in an afternoon, but useful enough to catch a real problem.
Two parts of the stack are easy to confuse:
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
npm install -g @aws/agentcore #install agentcore
which -a agentcore # Must print exactly ONE path.
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
The command creates two directories: configuration in agentcore/
and your code in app/
. You only need to edit app/
.
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",
},
)
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"],
}
_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],
}
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.
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 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,
}
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")
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)
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
python
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],
)
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.
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
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:
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.
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 🖥️:
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.