The best AI agent memory is selective: it keeps durable facts, preferences, and events and drops the small talk. Here is how to build that in Strands, three ways.
📦 Clone and ⭐ stop-ai-agents-losing-memory-sample-for-aws
Everyone is racing to make agents remember more. Bigger context windows, longer histories, a vector store that keeps everything. But the agent that wins is not the one that remembers the most, it is the one that keeps the right things and throws the rest away. Store everything and your agent's memory becomes expensive, slow, and dirty; store nothing and it forgets its user between sessions. The earlier posts in this series covered where memory lives (key-value, vector, graph). This one is about the decision that comes before all of them:
What is worth storing, and what should you throw away?
That decision is called memory extraction (or selective memory), and this post builds it three ways against the same planted conversation with deterministic ground truth (companion demo). The first two run on Strands Agents' native memory framework: you attach a MemoryManager, and the SDK runs extraction, storage, retrieval, and injection for you. The third is fully managed by AWS:
- Native MemoryManager, one store: the framework does memory; you write one selection prompt that decides what to keep.
- Native MemoryManager, four typed stores: the same framework, one store and one prompt per memory type (Amazon Bedrock AgentCore Memory's partitioning, native SDK).
- Managed memory: you send raw turns and a managed service extracts asynchronously (Amazon Bedrock AgentCore Memory).
The code uses Strands Agents, Amazon S3 Vectors or Amazon DynamoDB Vector Search for the vector store, and Amazon Bedrock AgentCore Memory for the managed option.
What counts as memory extraction?
Memory extraction, also called selective memory, is the step between a conversation and a memory store. It decides what to keep, which memory type it belongs to, and what to throw away. It is separate from the storage backend: extraction decides what enters memory, the backend decides where it lives.
The four memory types are the same across this whole series, and they map one-to-one to the four built-in strategies Amazon Bedrock AgentCore Memory offers (built-in strategies):
| Type | What it holds | AgentCore built-in strategy |
|---|---|---|
| facts | durable facts about the user's world | semanticMemoryStrategy |
| preferences | likes/dislikes the user reveals | userPreferenceMemoryStrategy |
| trip_summary | rolling summary of the current task | summaryMemoryStrategy |
| episodes | notable events, one entry each | episodicMemoryStrategy |
Strands does memory for you, natively
You don't hand-roll memory tools, and you don't put memory logic in the chat agent's system prompt. Strands ships a native MemoryManager you attach to the agent. It handles three jobs across the stores you give it: recall (a search_memory tool the agent can call), injection (folding relevant memory into the prompt before each call, without touching durable history), and extraction (a ModelExtractor that distills conversation into memories, off the turn, on a trigger). Recall and injection are on by default; extraction is opt-in.
You own exactly two things: the extractor's selection prompt (the keep/discard policy) and the store (where memories live and how they're searched). Everything else is the framework's job.
from strands import Agent
from strands.memory import MemoryManager, ModelExtractor, ExtractionConfig, IntervalTrigger
from strands.models.openai import OpenAIModel
# The selection prompt IS the keep/discard policy: the only memory logic you write.
SELECTION_PROMPT = (
"Extract durable memories worth keeping about a traveler: identity, dietary "
"restrictions and allergies, stated travel preferences, and confirmed bookings. "
"Discard small talk, weather, and passing opinions. "
'Return ONLY a JSON array of {"content": string}, or [] if there is nothing to keep.'
)
# A store implementing the native MemoryStore contract, backed by a vector index.
store = VectorMemoryStore(
name="traveler_memory",
extraction=ExtractionConfig(
trigger=[IntervalTrigger(turns=1)], # when extraction runs (off the turn)
extractor=ModelExtractor( # HOW selection happens
model=OpenAIModel(model_id="gpt-4o-mini"), # a separate, optionally cheaper model
system_prompt=SELECTION_PROMPT, # <-- the policy you own
),
),
)
agent = Agent(
model=model,
system_prompt="You are a flight assistant. Be concise.", # persona only, no memory logic
memory_manager=MemoryManager(stores=[store]),
)
agent("Hi, I'm Sam, vegetarian with a shellfish allergy.") # extraction happens automatically
The chat agent's system prompt stays about the agent's job. The selection policy lives in the ModelExtractor, a separate model call the framework runs off the turn, so it never bloats the conversational prompt and can even run on a cheaper model than the chat.
What each native piece does
You only touch four things, and the native memory framework handles the rest:
-
MemoryManager: the plugin you attach to the agent. It gives the agent asearch_memorytool, runs extraction in the background, and folds relevant memories into the prompt, all at once. -
ModelExtractor: the piece that decides what to keep. Itssystem_promptis your keep/discard policy, and it runs as a separate model call from the chat. -
ExtractionConfig: ties the extractor and a trigger to a store, and quietly strips tool-call noise so tool JSON never lands in memory. -
IntervalTrigger/InvocationTrigger: decide when extraction runs (every turn, or every N turns), off the conversation path.
Injection is on by default too: before each model call the manager pulls relevant memories into the input without touching the durable history, and if retrieval fails it just skips injection instead of breaking the turn. The point: you declare what to keep (the prompt) and where (the store); the framework handles the plumbing.
The store: your data, your backend
MemoryManager needs somewhere to persist and search. That's a MemoryStore, a small contract of add(content) and search(query). The demo implements it over a vector index so recall is semantic, with Amazon Titan Text Embeddings V2:
class VectorMemoryStore(MemoryStore):
name: str
writable = True # the manager may write extracted memories here
async def add(self, content, metadata=None):
vector = embed(content) # Titan V2
self._backend.put(key, content, vector) # S3 Vectors OR DynamoDB
async def search(self, query, options=None):
hits = self._backend.query(embed(query))
return [MemoryEntry(content=t, metadata={"score": s}) for t, s in hits]
Because the store is just this contract, the vector backend is a lever you set with one env var: VECTOR_BACKEND=s3 (Amazon S3 Vectors) or dynamodb (Amazon DynamoDB Vector Search). It's separate from what gets remembered.
Which one, and why. Both use the same Titan V2 embeddings, so recall quality is identical; the choice is about where the vectors live and how often you query them (this is exactly how the AWS docs frame it):
- Amazon S3 Vectors (docs): a dedicated vector bucket, separate from your operational data. AWS positions it for cost-optimized storage at massive scale with infrequent access: query latency is sub-second, around 100 ms or less for frequent queries and higher (up to a second or more) for cold ones. Pick it when memory is a standalone concern, you have a very large or archival vector corpus, and sub-second (not sub-10 ms) latency is fine.
-
Amazon DynamoDB Vector Search (docs): the vector index lives inside a DynamoDB table, so embeddings sit next to your operational data with no separate vector store to sync. AWS states single-digit-millisecond latency at 99%+ recall for real-time search. You create it with the same
CreateTable/UpdateTableAPIs (aVectorIndexesparameter) and query it with theSearchVectorsAPI, which needs a recentboto3. Pick it when your agent already reads from DynamoDB, or you want real-time retrieval and one service for data and memory.
In short, the AWS docs draw the line at access pattern: use S3 Vectors when memory is a standalone, large, or archival concern and sub-second latency is fine; use DynamoDB Vector Search when you are already on DynamoDB or want real-time retrieval with data and memory collocated. Neither is "faster memory" in a way the user feels, since the embedding call dominates end-to-end latency for both.
You don't always have to write the store, either. The Strands integrations directory lists ready-made MemoryStore backends: Amazon Bedrock Knowledge Base and AgentCore Memory from AWS, packaged S3 Vectors (s3-vectors-memory) and DynamoDB (strands-dynamodb-storage) stores, and partner options like Mem0, Zep, Vectorize, and Neo4j graph memory. Implementing the contract yourself, as this demo does, is the way to understand it; in production you'd often drop in one of those.
The three mechanisms, side by side
| Mechanism | What it is | Selection prompt | Partitions |
|---|---|---|---|
| A: native, one store |
MemoryManager + one MemoryStore
|
one general prompt | one |
| B: native, four typed stores |
MemoryManager + four MemoryStores |
one prompt per type | four (facts / prefs / summary / episodes) |
| C: Amazon Bedrock AgentCore Memory | fully managed by AWS | AWS (managed, or override) | managed |
A vs B is granularity, not backend. A is the simplest native setup: one store, one prompt. B reproduces AgentCore's per-type partitioning (four stores, four specialized prompts) with the native SDK, so the part AgentCore ships built-in (the selection criteria) becomes text you can read and tune. Both A and B run on S3 Vectors or DynamoDB (the VECTOR_BACKEND lever); the backend doesn't define the mechanism. C is the fully managed counterpart to B: you send raw turns, AWS extracts.
The measured results
The test conversation mixes 5 keepers (2 facts, 2 preferences, 1 episode) with 3 decoys to throw away (small talk, a passing opinion, ephemeral weather). The score is selection recall: how many of the 5 keepers a mechanism stored, checked deterministically against that ground truth (no LLM judge). The decoys are there so a mechanism cannot win by hoarding: keeping everything would ace recall and still be useless.
From 20 runs each for A and B, and repeated runs for C (gpt-4o-mini; the extractor is an LLM, so A's and B's exact recall varies slightly run to run, C's result was consistent, and your numbers will differ):
| Mechanism | Selection recall | Who owns the selection policy | Retrieval granularity | Turn latency | When queryable |
|---|---|---|---|---|---|
| A: native, one store | ~3.9/5 (3-5) | you (one prompt) | one blended pool | ~3.2 s/turn | when the turn returns |
| B: native, four typed stores | ~5/5 (4.95) | you (one prompt per type) | per type (query/inject/tune each alone) | ~3.5 s/turn | when the turn returns |
| C: Amazon Bedrock AgentCore Memory | 5/5 | AWS (managed, or override) | managed per strategy | ~2.2 s/turn | ~20-55 s later (async) |
The reproducible finding, across all those runs: all three recall the keepers well. What differs is who writes the selection policy, and that is a choice, not a verdict:
- B is the sharpest when you want to own every criterion. One specialized, non-overlapping prompt per type means each store keeps only its own kind of memory (facts vs preferences vs a confirmed-trip summary vs a completed action), so it lands recall ~5/5 on every run and drops the decoys. Writing four tight prompts is the work; per-type selection is the payoff. Choose B when the keep/discard rules are yours to define and tune.
- A is the same idea with the least setup. One store, one prompt: you own the selection policy at a coarser grain. It rejects small talk, weather, and opinions; a single prompt covering everything recalls a touch less consistently (~3.9/5). Choose A when one flat memory and one prompt are enough.
- C lets AWS do the selection for you. You send raw turns and Amazon Bedrock AgentCore Memory's managed strategies extract, embed, and index them, with no extraction pipeline to maintain. It recalls the keepers (5/5) and runs the memory lifecycle server-side. Choose C when you would rather not own the selection logic. If you do want to shape it, AgentCore supports custom strategies with prompt overrides: override a built-in strategy's default logic with your own prompt and model, so control is there on the managed path too.
Nothing here is instant. Every mechanism runs an extraction step, embeds the kept text, and writes it. A and B pay that cost inside the turn, so the memory is queryable the moment the turn returns. C pays it asynchronously on AWS: the turn is cheap (~2.2 s) but the extracted memory appears ~20-55 seconds later (measured, waiting for extraction to settle). Same work, moved off the turn, for a delay before the memory is usable.
The takeaway is not "more stores is better." It is how much of the selection policy you want to hold: A and B put the prompt in your hands (one prompt, or one per type for finer control); C hands the whole pipeline to AWS, with custom strategies as the way back in if you want it. Same goal, different amount of control, pick the one that fits your team.
A note on flush(): when is a memory saved?
Because extraction runs in the background, the last turn's memory might not be persisted yet when the agent finishes responding. await manager.flush() closes that gap: it forces every store to save its buffered messages (even one whose trigger hasn't fired, or one currently backed off) and waits for those writes to land. It's the synchronization point that guarantees nothing is lost on a graceful shutdown.
When you call it depends on how you drive the agent:
-
Synchronous
agent("...")(this demo): each call runs in its own event loop, so the framework flushes for you after every invocation. Memory is persisted by the time the call returns: you never flush manually. -
Async
agent.invoke_async(...)/stream_async(...): these share your long-lived loop and don't flush, so extraction stays on its trigger cadence, and youawait memory_manager.flush()yourself at a shutdown boundary.
Two caveats from the docs: don't call flush() every turn alongside a periodic trigger (it defeats the trigger's schedule), and a hard kill (SIGKILL, timeout) can still drop the last unsaved turn since flush never runs, so a more frequent trigger narrows that window.
What B's four typed prompts look like
Mechanism B is where owning the prompt pays off most, so it's worth seeing its policy. Each memory type maps to its own vector partition and its own selection prompt, in one table:
# {memory_type: (vector_partition, selection_prompt)}
# Each prompt keeps ONLY its own kind of memory and explicitly rejects the others,
# so the four stores never overlap: nothing lands in two stores, and no decoy slips
# in disguised as a "summary". That discipline is what makes B's per-type selection sharp.
TYPED = {
"facts": ("selective-facts", "Extract ONLY durable FACTS about the traveler (name, home airport, "
"dietary restrictions, allergies). DISCARD preferences, opinions, "
"small talk, weather, and one-off events. If none, return []."),
"preferences": ("selective-prefs", "Extract ONLY stated travel PREFERENCES (cabin, seat, layover rules, "
"budget). DISCARD facts like allergies, one-off bookings, opinions, "
"small talk, weather. If none, return []."),
"trip_summary": ("selective-summary", "Maintain a one-sentence summary of the CONFIRMED current trip ONLY "
"(route, airline, date once booked). DISCARD small talk, weather, "
"opinions, and anything not part of the booked trip. If unchanged, return []."),
"episodes": ("selective-episodes", "Record ONLY a concrete completed ACTION the traveler took this turn "
"(a booking, a cancellation, a confirmed change). Not a comment, question, "
"opinion, weather remark, or small talk. If none, return []."),
}
# Every prompt ends with the same output contract, appended when the extractor is built:
JSON_CONTRACT = ' Return ONLY a JSON array of {"content": string}, or [] if none.'
Returning [] is a first-class answer; that's the discard half of selection. If the extractor keeps a decoy, you tune the prompt. On the managed path (C) those defaults live in the service, and you can still reach them through custom strategy overrides. (If you don't pass a prompt to the ModelExtractor, it uses Strands' sensible default, but then you inherit its generic criteria.)
Which mechanism should you pick?
| Situation | Pick |
|---|---|
| You want the SDK to run memory for you with the least setup, and one selection prompt is enough | A: native, one store |
| You need per-type control of the keep/discard criteria (regulated domain, custom taxonomy) | B: native, four typed stores |
| Production multi-user; asynchronous extraction (seconds of lag) is fine; you want AWS to run the whole memory pipeline for you (with custom strategies available if you later want to shape it) | C: Amazon Bedrock AgentCore Memory |
Two levers cut across all of this:
- Backend: S3 Vectors vs DynamoDB Vector Search is a one-env-var choice for A and B; pick by where your operational data already lives.
-
Destination: a
MemoryStorecould just as well write facts to the knowledge graph of Demo 03 (as triples). Selection and storage compose.
Practical notes for the managed path
A few things worth knowing when you wire up Amazon Bedrock AgentCore Memory through the official Strands session manager (AgentCoreMemorySessionManager):
-
Give each strategy an explicit namespace at creation (
/facts/{actorId}/,/preferences/{actorId}/,/summaries/{actorId}/{sessionId}/,/episodes/{actorId}/{sessionId}/). The same namespace you set on the strategy is the one you reference inRetrievalConfig. -
Use
RetrievalConfig(relevance_score=...)to control what comes back at recall. It keeps only records above a relevance threshold per namespace, so the agent sees the most on-point memories. - Extraction is asynchronous. In this demo the extracted memory became queryable ~20 to 55 seconds after the turn (measured, polling until extraction settled). Plan for eventual consistency: a fact written this turn may not be retrievable on the next one.
-
A memory in
CREATINGstatus isn't ready yet. Wait until it reportsACTIVEbefore sending events. - Want to shape what the managed strategies keep? Use custom strategies with prompt overrides: override a built-in strategy's default extraction/consolidation logic with your own prompt and model, so you get the managed pipeline and your own criteria.
The service evolves quickly, so treat the exact behaviors above as current observations and check the docs for the latest.
Try it
Everything runs from Demo 04 of the companion repo: the three mechanisms against the same conversation, with the deterministic scorecard and the lag measurement. AWS resources (vector indexes/tables, the managed memory) are created automatically if missing, and the README covers cleanup and the exact native Strands pieces used.
There is also one interactive chat per mechanism (chat_single_store.py, chat_typed_stores.py, chat_agentcore.py): talk to the agent and watch memory fill turn by turn, with small talk discarded and keepers stored. The AgentCore chat lets you feel the async lag: right after you speak, /memory shows nothing until extraction catches up.
This post was about throwing away noise. Next in the series, the higher-stakes version of the same instinct: what your agent must NOT remember even when it looks legitimate, and how to defend the write path against prompt injection and memory poisoning.
Research referenced
| Paper | Theme |
|---|---|
| MIRIX: Multi-Agent Memory System | Typed memory (6 types, +35% accuracy, SOTA 85.4% on LOCOMO) |
| MemGPT: Towards LLMs as Operating Systems | Core memory concept, virtual context management |
We reproduce the mechanism these papers describe (typed, selective memory), not their specific benchmark numbers.
¡Gracias!

Top comments (0)