Published @ AWS Builder Center
In the previous blog post, I implemented a simple Generator–Evaluator pattern using LangGraph, where an evaluator agent reviewed and refined content produced by a generator agent. While effective, the workflow followed a predefined sequence with limited autonomy.
In this iteration, I take the architecture a step further by introducing a Planner Agent that can dynamically decide what actions to take next. Rather than following a fixed workflow, the planner evaluates the current state, determines which agent should be invoked, and orchestrates the overall process. This creates a more agentic and autonomous system, enabling the workflow to adapt its behavior based on context instead of relying solely on hardcoded execution paths.
In this small proof-of-concept project, I built an agentic blog post writer using AWS Strands Agents. The workflow follows a Planner–Generator–Evaluator architecture, where a planner agent orchestrates the process, a generator agent creates content, and an evaluator agent reviews and improves the output through iterative feedback.
Architecture:
PlannerAgent: decides what to do next (plan / replan dynamically)
- calls @tool: generate_blog → GeneratorAgent (research + write)
- calls @tool: evaluate_blog → EvaluatorAgent (quality gate)
- calls @tool: summarize_article → SummarizerAgent (key-point extraction)
- calls @tool: search_web → DDGS search
- calls @tool: fetch_page → HTTP scraper (full text, no truncation)
- calls @tool: read_memory → persistent Markdown memory
- calls @tool: write_memory → persistent Markdown memory
The key difference from pipeline:
- The Planner's reasoning loop (via Strands agent loop) selects tools at runtime; it can re-search, summarise extra sources, rewrite, skip evaluation, or stop early.
- GeneratorAgent and EvaluatorAgent are themselves Strands Agents exposed as @tool callables (agents-as-tools pattern).
- SummarizerAgent is a dedicated low-temperature agent that fetches the full page and distills 5-10 key technical bullets per article before passing them to the writer.
- No fixed edges: the Planner decides whether to call evaluate_blog or generate_blog next based on EvalResult feedback and iteration count.
Code GitHub Link: Project on GitHub
Whether you're exploring agent design or building your own system, this will give you a clear, practical starting point 😉
Table of Contents
- Dependencies & Configuration
- Implementing Memory
- Fetching Information & Summary Agent
- Agents State & Agent Tools
- Generator & Evaluator Agents
- Planner Agent & Main Run
- All Code & Demo
- Memory.MD File
- Generated Blog Post
- Conclusion
- References
Dependencies & Configuration
- Please install dependencies:
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
# deactivate
Requirements.txt:
strands-agents>=0.1.0
strands-agents-tools>=0.1.0
boto3>=1.34.0
python-dotenv>=1.0.0
duckduckgo-search>=8.1.1
ddgs>=9.14.1
requests>=2.33.0
-
Enable AWS Bedrock model access in your region (e.g. eu-central-1, us-east-1)
- AWS Bedrock > Bedrock Configuration > Model Access > AWS Nova-Pro, or Claude Sonnet, Opus
In this code, we'll use AWS Nova-Pro, because it's served in different regions by AWS.
After model access, give permission in your IAM to access AWS Bedrock services: AmazonBedrockFullAccess
-
2 Options to reach AWS Bedrock Model using your AWS account:
-
AWS Config: With
aws configure, to createconfigandcredentialsfiles - Getting variables using .env file: Add .env file:
-
AWS Config: With
AWS_ACCESS_KEY_ID= PASTE_YOUR_ACCESS_KEY_ID_HERE
AWS_SECRET_ACCESS_KEY=PASTE_YOUR_SECRET_ACCESS_KEY_HERE
- Another alternative way to create Bedrock API key (long or short term).
Implementing Memory
- Memory Functions (init, read, append):
import re, sys, json, datetime, requests
from pathlib import Path
from dotenv import load_dotenv
# Load Env, Assign Variables
load_dotenv()
OUT = Path("output"); OUT.mkdir(exist_ok=True)
BLOG = OUT / "blog_post.md"
MEM = OUT / "memory.md"
SITE = "dev.to"
MIN_ARTICLES = 5
MAX_ITER = 3
# Persistent memory
def _mem_init(topic: str) -> None:
MEM.write_text(
f"# Memory — {topic} ({datetime.datetime.now():%Y-%m-%d %H:%M})\n\n---\n\n"
"## Research\n\n## Sources\n\n## Critiques\n\n## Log\n",
encoding="utf-8",
)
def _mem_read() -> str:
return MEM.read_text(encoding="utf-8") if MEM.exists() else ""
def _mem_append(section: str, content: str) -> None:
text = _mem_read()
eol = text.index("\n", text.index(f"## {section}") + len(f"## {section}"))
MEM.write_text(text[:eol+1] + "\n" + content.strip() + "\n" + text[eol+1:], encoding="utf-8")
Fetching Information & Summary Agent
- Research functions (keywords generation, search on DuckDuckGo, fetch and collect), in addition, Summarizer agent gets all input to create important bullet/key points:
from strands import Agent, tool
from strands.models.bedrock import BedrockModel
# Raw fetch (full page for summarizer)
def _fetch_raw(url: str, snippet: str = "") -> str:
"""Fetch a page and return ALL readable text (no char limit)."""
try:
html = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10).text
# dev.to: strip nav header that precedes the article body
if SITE in url:
m = re.search(r"(?: \s*){3,}", html)
if m:
html = html[m.end():]
text = re.sub(r"<(style|script)[^>]*>.*?</\1>", " ", html, flags=re.DOTALL)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s{2,}", " ", text).strip()
return text if len(text) >= 300 else (snippet or "[unavailable]")
except Exception as e:
return f"[failed: {e}]"
def _ddgs_search(query: str) -> list[dict]:
def run(q: str) -> list[dict]:
with DDGS() as d:
raw = list(d.text(q, max_results=MIN_ARTICLES * 4))
return [{"title": r["title"], "url": r["href"], "snippet": r["body"]}
for r in raw if SITE in r.get("href", "")]
for q in [f"site:{SITE} {query}", f"{query} {SITE}"]:
try:
hits = run(q)
if hits:
return hits[:MIN_ARTICLES]
except Exception:
continue
return []
# Summarizer Agent
_summary_model = BedrockModel(model_id="us.amazon.nova-pro-v1:0", temperature=0.0)
def _summarize(title: str, url: str, full_text: str, topic: str) -> str:
"""Use an LLM to extract key technical points from a full article."""
agent = Agent(
model=_summary_model,
system_prompt=(
"You are a research assistant. Given raw article text, extract the most important "
"technical points relevant to the given topic. Be concise and specific. "
"Output a bullet-point summary (5-10 bullets). No preamble, no URLs, just bullets."
),
)
# Cap input to 12 000 chars, enough to cover a full dev.to post
truncated = full_text[:12_000]
response = agent(
f"Topic we are researching: {topic}\n\n"
f"Article title: {title}\n"
f"Article URL: {url}\n\n"
f"Full article text:\n{truncated}\n\n"
"Extract the key technical points relevant to the topic."
)
return str(response).strip()
def _collect_articles(keywords: list[str], topic: str) -> tuple[str, list[str]]:
"""Search, fetch full pages, summarize each, return (markdown_block, url_list)."""
seen, found = set(), []
for kw in keywords:
if len(found) >= MIN_ARTICLES:
break
for hit in _ddgs_search(kw):
if hit["url"] in seen or len(found) >= MIN_ARTICLES:
continue
seen.add(hit["url"])
raw = _fetch_raw(hit["url"], hit["snippet"])
if raw.startswith("["):
continue
print(f" ↳ summarising [{len(found)+1}/{MIN_ARTICLES}] {hit['url']}")
summary = _summarize(hit["title"], hit["url"], raw, topic)
if len(summary) < 50:
continue
found.append({**hit, "summary": summary})
if len(found) < MIN_ARTICLES:
print(f" ⚠ only {len(found)}/{MIN_ARTICLES} articles summarised")
sections = [
f"### [{a['title']}]({a['url']})\n**Snippet:** {a['snippet']}\n\n**Key Points:**\n{a['summary']}\n"
for a in found
]
return "\n---\n".join(sections) or "_No results._", [a["url"] for a in found]
Agents State & Agent Tools
- State needs to communicate between agents (EvalResult). Planner agent calls agent tools (research, summarize, read & write memory).
from strands import Agent, tool
from typing import Optional
from pydantic import BaseModel, Field
# State
_state: dict = {}
def _state_init(topic: str, max_iter: int) -> None:
_state.update(topic=topic, max_iter=max_iter, iteration=0,
blog="", feedback=None, keywords=[], done=False)
# EvalResult schema
class EvalResult(BaseModel):
verdict: str
depth_score: int = Field(ge=1, le=5)
recency_score: int = Field(ge=1, le=5)
structure_score: int = Field(ge=1, le=5)
writing_score: int = Field(ge=1, le=5)
feedback: Optional[str] = None
@tool
def search_web(query: str) -> str:
"""Search dev.to for technical articles matching the query.
Returns a JSON list of {title, url, snippet} objects."""
return json.dumps(_ddgs_search(query), ensure_ascii=False)
@tool
def fetch_page(url: str) -> str:
"""Fetch and return the full readable text content of a web page."""
return _fetch_raw(url)
@tool
def summarize_article(url: str, title: str = "") -> str:
"""Fetch a web page and return an LLM-generated bullet-point summary
of its key technical points relevant to the current pipeline topic.
Use this instead of fetch_page when you want distilled insight, not raw text."""
raw = _fetch_raw(url)
if raw.startswith("["):
return raw
return _summarize(title or url, url, raw, _state.get("topic", ""))
@tool
def read_memory() -> str:
"""Read the current persistent memory (research notes, sources, critiques, log)."""
return _mem_read() or "Memory is empty."
@tool
def write_memory(section: str, content: str) -> str:
"""Append content to a memory section.
Valid sections: Research, Sources, Critiques, Log."""
try:
_mem_append(section, content)
return f"✓ Written to ## {section}"
except Exception as e:
return f"✗ Failed: {e}"
Generator & Evaluator Agents
- Generator create keywords, research, appends the data (links, summary) into the Memory, then generates the blog post.
from strands import Agent, tool
from strands.models.bedrock import BedrockModel
# Generator Agent
_gen_model = BedrockModel(model_id="us.amazon.nova-pro-v1:0", temperature=0.8)
@tool
def generate_blog(extra_instructions: str = "") -> str:
"""Run the GeneratorAgent: collect + summarise research, then write/rewrite the blog post."""
topic = _state["topic"]
iteration = _state["iteration"] + 1
keywords = _state["keywords"]
prev_blog = _state["blog"]
feedback = _state["feedback"]
print(f"\n [GeneratorAgent] writing iter {iteration}/{_state['max_iter']}")
# Step 1: keyword generation
if not keywords:
kw_agent = Agent(model=_gen_model,
system_prompt="Return ONLY a JSON array of strings. No prose, no explanation.")
kw_text = str(kw_agent(f"8 diverse search queries for: {topic}")).strip()
m = re.search(r"\[.*\]", kw_text, re.DOTALL)
try: keywords = json.loads(m.group() if m else kw_text)[:8]
except: keywords = [topic]
_state["keywords"] = keywords
# Step 2: collect + summarise
research, urls = _collect_articles(keywords, topic)
now = datetime.datetime.now().strftime("%H:%M:%S")
# Persist to memory BEFORE calling the writer LLM
_state["iteration"] = iteration
_mem_append("Research", f"### Iter {iteration} — {now}\n**KW:** {', '.join(keywords)}\n\n{research}\n")
_mem_append("Sources", f"### Iter {iteration}\n" + "\n".join(f"- {u}" for u in urls) + "\n")
_mem_append("Log", f"- **Iter {iteration}** `{now}` — {len(urls)} articles\n")
# Step 3: write with a fresh writer agent
rewrite_block = ""
if feedback and iteration > 1:
rewrite_block = f"\n\nPREVIOUS DRAFT:\n{prev_blog[:3000]}\n\nEVALUATOR FEEDBACK:\n{feedback}"
memory_ctx = _mem_read().split("## Log")[0][:4000]
gen_agent = Agent(
model=_gen_model,
tools=[search_web, summarize_article, write_memory],
system_prompt=(
"You are an expert technical writer for senior AI/ML engineers. "
"You may call summarize_article(url) to get distilled insights from any URL. "
"Produce a complete Markdown blog post: # title, ## sections, code examples, "
"≥8 cited sources, ## References at the end. "
"Output ONLY the Markdown — no JSON, no prose, no explanations."
),
)
blog = str(gen_agent(
f"Write a 1500-2000 word technical blog post about **{topic}** for senior AI/ML engineers.\n\n"
f"MEMORY CONTEXT:\n{memory_ctx}\n\n"
f"RESEARCH (pre-summarised key points per article):\n{research}"
f"{rewrite_block}\n\n"
f"EXTRA INSTRUCTIONS: {extra_instructions}\n\n"
"Output ONLY the Markdown blog post. Start directly with the # title."
)).strip()
BLOG.write_text(blog, encoding="utf-8")
_state["blog"] = blog
print(f" ↳ blog: {len(blog)} chars | {len(urls)} sources")
return f"Blog written: {len(blog)} chars, {len(urls)} sources, iteration {iteration}."
- Evaluator evaluate the post in certain criterias (depth, recency, structure, writing), appends the output into the Memory.
from strands import Agent, tool
from strands.models.bedrock import BedrockModel
# Evaluator Agent
_eval_model = BedrockModel(model_id="us.amazon.nova-pro-v1:0", temperature=0.0)
@tool
def evaluate_blog() -> str:
"""Run the EvaluatorAgent on the current blog draft. Returns JSON: verdict (accepted|rejected), four 1-5 scores, feedback. verdict='accepted' means ALL scores ≥ 4."""
topic = _state["topic"]
iteration = _state["iteration"]
if not BLOG.exists():
return json.dumps({"verdict": "rejected", "feedback": "No blog post found. Generate one first."})
print(f"\n [EvaluatorAgent] reviewing iter {iteration}")
eval_agent = Agent(
model=_eval_model,
tools=[read_memory],
system_prompt=(
"You are a rigorous technical editor scoring blog posts for senior AI/ML engineers. "
"Use read_memory to check research history and prior critiques before scoring. "
"Respond ONLY with valid JSON — no markdown fences, no prose."
),
)
text = str(eval_agent(
f"Evaluate this blog post on **{topic}** for senior AI engineers.\n\n"
f"POST:\n{BLOG.read_text(encoding='utf-8')}\n\n"
"Score 1-5 each: depth, recency (2023-2025), structure, writing. Accept only if ALL ≥ 4.\n\n"
'Return ONLY: {"verdict":"accepted"|"rejected","depth_score":int,"recency_score":int,'
'"structure_score":int,"writing_score":int,"feedback":"str or null"}'
)).strip()
text = re.sub(r"^[a-z]*\n?", "", text).rstrip("`").strip()
m = re.search(r"\{.*\}", text, re.DOTALL)
try:
e = EvalResult(**(json.loads(m.group() if m else text)))
except Exception as ex:
print(f" ⚠ parse error: {ex}")
e = EvalResult(verdict="rejected", depth_score=1, recency_score=1,
structure_score=1, writing_score=1, feedback=str(ex))
print(f" ↳ D:{e.depth_score} R:{e.recency_score} S:{e.structure_score} W:{e.writing_score} → {e.verdict.upper()}")
now = datetime.datetime.now().strftime("%H:%M:%S")
_mem_append("Critiques",
f"### Iter {iteration} — {now} — **{e.verdict.upper()}**\n"
f"| Depth | Recency | Structure | Writing |\n|---|---|---|---|\n"
f"| {e.depth_score}/5 | {e.recency_score}/5 | {e.structure_score}/5 | {e.writing_score}/5 |\n"
+ (f"\n**Feedback:** {e.feedback}\n" if e.feedback else "")
)
_mem_append("Log",
f"- **Eval iter {iteration}** `{now}` — {e.verdict.upper()} "
f"(D:{e.depth_score} R:{e.recency_score} S:{e.structure_score} W:{e.writing_score})\n"
)
_state["feedback"] = e.feedback or ""
if e.verdict == "accepted":
_state["done"] = True
return e.model_dump_json()
Planner Agent & Main Run
- Planner agent decides to call which tools and agents.
from strands import Agent, tool
from strands.models.bedrock import BedrockModel
# Planner Agent
_plan_model = BedrockModel(model_id="us.amazon.nova-pro-v1:0", temperature=0.3)
def run(topic: str, max_iter: int = MAX_ITER) -> None:
print(f"\n{'='*60}\n TOPIC : {topic}\n MAX ITER: {max_iter}\n{'='*60}")
_state_init(topic, max_iter)
_mem_init(topic)
planner = Agent(
model=_plan_model,
tools=[generate_blog, evaluate_blog, search_web, fetch_page,
summarize_article, read_memory, write_memory],
system_prompt=(
"You are an autonomous blog-pipeline orchestrator for senior AI/ML engineering content.\n\n"
"Goal: produce a high-quality technical blog post (all eval scores ≥ 4) "
f"within the allowed iterations.\n\n"
"Tools available:\n"
" • generate_blog(extra_instructions) — research + summarise + write / rewrite\n"
" • evaluate_blog() — score the current draft\n"
" • search_web(query) — ad-hoc article search\n"
" • fetch_page(url) — full raw text of a page\n"
" • summarize_article(url, title) — LLM-distilled key points from a page\n"
" • read_memory() — inspect research & critiques\n"
" • write_memory(section, content) — log planning notes\n\n"
"Flow:\n"
" 1. generate_blog() → 2. evaluate_blog() → 3. if rejected, generate_blog(extra_instructions=<fix>) → repeat.\n"
" Stop when accepted or iteration budget exhausted.\n"
" Use summarize_article between steps when you need deeper insight on a specific source."
),
)
print("\n[PlannerAgent] starting autonomous pipeline...\n")
planner(
f"Topic: **{topic}**\n"
f"Max iterations allowed: {max_iter}\n\n"
"Run the full pipeline autonomously. Generate, evaluate, and iterate "
"until the post is accepted or the budget is exhausted."
)
print(f"\n{'='*60}")
print(f" Done — iteration {_state['iteration']} | accepted={_state['done']}")
print(f" Output: {BLOG} | Memory: {MEM}")
print(f"{'='*60}\n")
if __name__ == "__main__":
run(" ".join(sys.argv[1:]) or "AI Agents with Memory on AWS Bedrock AgentCore")
All Code & Demo
GitHub Link: Project on GitHub
Run:
python3 agent.py
Memory.MD File
- Generator collects blog posts from Dev.To with their links and content.
- Generator collects all links under sources for references.
## Research
### Iter 1 — 15:48:54
**KW:** AI Agents with Memory on AWS Bedrock AgentCore, Benefits of AI Agents with Memory using AWS Bedrock AgentCore, Implementing AI Agents with Memory on AWS Bedrock AgentCore, Best practices for AI Agents with Memory on AWS Bedrock AgentCore, Comparing AI Agents with Memory on AWS Bedrock AgentCore vs other platforms, Case studies of AI Agents with Memory on AWS Bedrock AgentCore, Security considerations for AI Agents with Memory on AWS Bedrock AgentCore, Future trends for AI Agents with Memory on AWS Bedrock AgentCore
### [Bring AI agents with Long-Term memory into... - DEV Community](https://dev.to/aws/bring-ai-agents-with-long-term-memory-into-production-in-minutes-338l)
**Snippet:** Amazon Bedrock AgentCore Memory solves this. Your agents remember users across sessions, learn from past interactions, and provide personalized experiences that persist beyond session boundaries. This tutorial shows you how to add long-term memory to your agents.
**Key Points:**
- AgentCore Runtime provides built-in short-term memory within sessions (up to 8 hours or 15 minutes of inactivity).
- AgentCore Memory adds cross-session persistence, allowing agents to remember users and their preferences across different sessions.
- AgentCore Memory automatically extracts and stores key insights and user preferences for long-term retention.
- Three built-in memory strategies: User Preferences, Semantic, and Session Summaries.
- Custom headers (e.g., X-Amzn-Bedrock-AgentCore-Runtime-Custom-Actor-Id) are used to pass user identifiers for user-centric memory storage.
- Long-term memory extraction is an asynchronous process, requiring several minutes between storing and retrieving memories.
- The tutorial demonstrates setting up, configuring, and testing an AI agent with cross-session memory on AWS Bedrock AgentCore....
## Sources
### Iter 1
- https://dev.to/aws/bring-ai-agents-with-long-term-memory-into-production-in-minutes-338l
- https://dev.to/aws/build-production-ai-agents-with-managed-long-term-memory-2jm
- https://dev.to/sampathkaran/aws-bedrock-agentcore-memory-give-your-ai-agent-a-brain-that-actually-remembers-12ie
- https://dev.to/yuriybezsonov/ai-agent-memory-made-easy-amazon-bedrock-agentcore-memory-with-spring-ai-3bng
- https://dev.to/sudarshangouda/ai-agent-memory-from-manual-implementation-to-mem0-to-aws-agentcore-2d7c
- Evaluator puts its critiques under Memory.MD
## Critiques
### Iter 1 — 15:49:15 — **ACCEPTED**
| Depth | Recency | Structure | Writing |
|---|---|---|---|
| 5/5 | 5/5 | 5/5 | 5/5 |
## Log
- **Eval iter 1** `15:49:15` — ACCEPTED (D:5 R:5 S:5 W:5)
GitHub Link: Memory.MD on GitHub
Generated Blog Post
- It creates blog post. However, our aim is not to create perfect blog post.
# AI Agents with Memory on AWS Bedrock AgentCore
## Introduction
AI agents have become integral to various applications, providing personalized and context-aware interactions. However, traditional stateless models often lack the ability to retain information across sessions, limiting their effectiveness. AWS Bedrock AgentCore addresses this challenge by offering a fully managed memory service for AI agents, enabling them to remember users and their preferences across different sessions. This blog post explores the benefits, implementation, and best practices for using AI agents with memory on AWS Bedrock AgentCore.
## Benefits of AI Agents with Memory using AWS Bedrock AgentCore
### Personalized User Experiences
AI agents with memory can provide highly personalized experiences by remembering user preferences and past interactions. This capability allows agents to offer tailored recommendations and responses, enhancing user satisfaction and engagement.....
GitHub Link: Blog Post Output on GitHub
Conclusion
In this post, we mentioned:
- how to create agentic app with AWS Strands Agent,
- how to use memory files (markdown files) between multi-agents,
- how to create Planner, Generator, Evaluator Agents.
- how to use AWS Bedrock Nova.
In this small project, I explored how to build an agentic blog writer with AWS Strands Agents. By combining Planner, Generator, and Evaluator agents, the system can autonomously coordinate content creation, review its own output, and iteratively improve the final article.
If you found the tutorial interesting, I’d love to hear your thoughts in the blog post comments. Feel free to share your reactions or leave a comment. I truly value your input and engagement 😉
For other posts 👉 https://dev.to/omerberatsezer 🧐
References
- https://strandsagents.com/
- https://aws.amazon.com/bedrock
- https://github.com/omerbsezer/Fast-LLM-Agent-MCP/
Your comments 🤔
I’d love to hear how others are building agentic workflows.
- Are you using planner-generator–evaluator patterns, memory layers, or multi-agent architectures in real projects?
- What are you thinking about AWS Strands Agents?
Drop your thoughts, feedback, or improvements in the comments, always curious to learn from different approaches. ☺️

Top comments (1)
For me, the most interesting shift in agentic workflows is moving from single smart prompts to systems of coordination. I’m especially curious about: How planners decompose tasks dynamically instead of following fixed chains, , How memory is structured (short-term vs long-term vs task-level context), How agents actually communicate (shared state vs message passing vs tool-mediated coordination). It'll be great to learn how others are handling failure recovery and memory drift in real systems.