Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"boto3>=1.43.31",
"botocore>=1.43.31",
"boto3>=1.43.35",
"botocore>=1.43.35",
"pydantic>=2.0.0,<2.41.3",
"urllib3>=1.26.0",
"starlette>=0.46.2",
Expand Down Expand Up @@ -149,7 +149,7 @@ dev = [
"ruff>=0.12.0",
"websockets>=14.1",
"wheel>=0.45.1",
"strands-agents>=1.20.0",
"strands-agents>=1.46.0",
"strands-agents-evals>=1.0.3,<2.0.0",
"deepeval>=3.5.0,<5.0.0",
"autoevals>=0.3.0,<1.0.0",
Expand All @@ -166,7 +166,7 @@ a2a = ["a2a-sdk[http-server]>=0.3,<0.4"]
a2a-v1 = ["a2a-sdk[http-server]>=1.0.1,<2.0"]
ag-ui = ["ag-ui-protocol>=0.1.10"]
strands-agents = [
"strands-agents>=1.20.0",
"strands-agents>=1.46.0",
"mcp>=1.23.0,<2.0.0",
]
langgraph = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Strands AgentCore MemoryStore

`AgentCoreMemoryStore` plugs AgentCore long-term memory directly into Strands' `MemoryManager` for
long-term recall and extraction. It requires `strands-agents>=1.46.0`.

## One namespace

A store is recall-only by default. Set `writable=True` on exactly one store when Strands should send
messages to AgentCore for server-side long-term extraction:

```python
import os

from strands import Agent
from strands.memory import MemoryManager

from bedrock_agentcore.memory.integrations.strands.memorystore import AgentCoreMemoryStore

store = AgentCoreMemoryStore(
memory_id=os.environ["AGENTCORE_MEMORY_ID"],
actor_id="demo-user",
session_id="demo-session",
namespace="/facts/{actorId}/",
writable=True,
extraction=True,
region_name="us-east-1",
)
manager = MemoryManager(stores=[store])
agent = Agent(memory_manager=manager)
agent("Remember that I prefer window seats.")
```

`namespace` performs exact-prefix retrieval. Use `namespace_path` instead to search a namespace
subtree. The integration resolves `{actorId}` and `{sessionId}` client-side; substitute other
placeholders and malformed braces before constructing the store.

## Multiple namespaces

`create_agentcore_memory_stores` returns `list[MemoryStore]` for direct `MemoryManager` composition.
Each item is a concrete `AgentCoreMemoryStore`; the factory shares one boto3 client and prevents
duplicate writes by allowing at most one writer:

```python
import os

from strands.memory import IntervalTrigger, MemoryManager, MemoryMessageFilter

from bedrock_agentcore.memory.integrations.strands.memorystore import create_agentcore_memory_stores

stores = create_agentcore_memory_stores(
memory_id=os.environ["AGENTCORE_MEMORY_ID"],
actor_id="demo-user",
session_id="demo-session",
namespaces=[
{
"namespace": "/preferences/{actorId}/",
"max_search_results": 5,
"min_score": 0.7,
},
{
"namespace": "/facts/{actorId}/",
"max_search_results": 10,
"min_score": 0.3,
},
],
extraction={
"cadence": IntervalTrigger(turns=10),
"filter": MemoryMessageFilter(exclude=["toolUse", "toolResult", "image"]),
},
region_name="us-east-1",
)
manager = MemoryManager(stores=stores)
```

With extraction enabled, the first namespace not explicitly marked `writable=False` becomes the
writer. Set `writable=True` on one namespace to choose it explicitly. Omit `extraction` or pass
`False` for recall-only stores.

## Search and write behavior

- Search defaults to 5 results. `min_score` enables client-side score filtering and over-fetches by
a factor of 4 (configurable with `over_fetch_factor`); only the over-fetched `topK` is capped at 100.
- Returned metadata uses reserved keys `_id`, `_score`, `_namespaces`, and `_createdAt`.
- Writes preserve user/assistant roles, ignore blank and tool-only messages, and batch up to 50
consecutive turns per AgentCore event by default. `max_turns_per_event` accepts any positive integer.
- `metadata_provider` returns scalar strings, finite numbers, or booleans. Strings pass through;
other finite scalars use Python `json.dumps` formatting. `None`, arrays, objects, non-finite numbers,
and values outside AgentCore's allowed character set are rejected locally.
- Direct `AgentCoreMemoryStore(...)` construction accepts `extraction_mode="SKIP"` to omit long-term
extraction for its events. The multi-namespace factory intentionally does not expose this option.
- `add_messages()` is the supported write interface. The flat-string Strands `add()` API is not
implemented because it loses role and turn information.

## Batching, cadence, and flush

Three separate controls determine write timing and cost:

1. **Batching is always on.** Each flush packs its role-tagged messages into as few `create_event`
requests as `max_turns_per_event` allows.
2. **Cadence controls when buffered messages are dispatched across turns.** `extraction=True` uses
Strands' default trigger. Pass an extraction config with an `IntervalTrigger` or another Strands
trigger to tune cadence.
3. **`flush()` lets pending write attempts settle; it does not acknowledge durability or server-side
extraction.** Strands 1.46 logs and swallows sender failures, rolls back its high-water mark, and
retains the failed batch for a later retry.

Synchronous `agent(...)` invocations flush automatically. After async invocation or streaming, call
`await manager.flush()` at a lifecycle or shutdown boundary to let pending writes settle. Monitor logs
or telemetry for failures rather than treating `flush()` as proof that data was persisted. AgentCore's
server-side extraction remains eventually consistent, so newly written records may not be immediately
searchable.

Reuse one manager per `(actor_id, session_id)` while that session is active. Reuse keeps trigger state
and buffered turns alive, allowing a coarser cadence to reduce calls. The application owns manager
caching and eviction.

## Namespace and error contract

Recall works only when the query namespace matches the concrete namespace where AgentCore stored the
extracted record. Writes append to the shared `(memory_id, actor_id, session_id)` stream; the memory
resource's strategies decide which namespaces receive extracted records. That is why a store set must
have at most one writer.

- AgentCore resolves strategy placeholders at extraction time, but retrieval does not. The store
resolves only `{actorId}` and `{sessionId}` and rejects remaining braces at construction.
- Match the namespace template used when provisioning the strategy. `namespace` queries one exact
prefix; `namespace_path` queries a parent subtree.
- A namespace containing `{sessionId}` is session-scoped. Use a stable session id or actor-only
namespace for cross-session recall.
- The store consumes an existing memory resource; it does not provision strategies or the resource.
- Retrieval failures propagate to `MemoryManager`, which applies its per-store partial-failure behavior.
- Sender failures remain buffered for retry and are logged by Strands rather than propagated by
`flush()`.

By default the integration builds its connection through this SDK's `MemoryClient` and calls the
`bedrock-agentcore` data-plane operations on it. Pass `client=` to reuse an existing connection —
either your own `MemoryClient` (its vended data-plane client is used) or a boto3 `bedrock-agentcore`
client. AWS credentials use boto3's normal credential chain; no credentials are stored by the
integration.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Strands-native long-term memory stores backed by Bedrock AgentCore Memory."""

from .factory import create_agentcore_memory_stores
from .sender import AgentCoreEventSender
from .store import AgentCoreMemoryStore
from .types import (
RESERVED_METADATA_PREFIX,
AgentCoreEventSenderConfig,
AgentCoreExactNamespaceStoreConfig,
AgentCoreExtractionConfig,
AgentCoreMemoryStoreConfig,
AgentCoreNamespaceConfig,
AgentCoreSubtreeStoreConfig,
CreateAgentCoreMemoryStoresInput,
ExtractionMode,
MetadataProvider,
MetadataValue,
resolve_namespace,
slugify_namespace,
)

__all__ = [
"RESERVED_METADATA_PREFIX",
"AgentCoreEventSender",
"AgentCoreEventSenderConfig",
"AgentCoreExactNamespaceStoreConfig",
"AgentCoreExtractionConfig",
"AgentCoreMemoryStore",
"AgentCoreMemoryStoreConfig",
"AgentCoreNamespaceConfig",
"AgentCoreSubtreeStoreConfig",
"CreateAgentCoreMemoryStoresInput",
"ExtractionMode",
"MetadataProvider",
"MetadataValue",
"create_agentcore_memory_stores",
"resolve_namespace",
"slugify_namespace",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Factory helpers for multi-namespace AgentCore memory topologies."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

port lgtm


from __future__ import annotations

from collections.abc import Sequence

import boto3
from strands.memory import ExtractionConfig, MemoryStore

from .store import AgentCoreMemoryStore, _create_data_plane_client
from .types import (
AgentCoreClient,
AgentCoreExtractionConfig,
AgentCoreNamespaceConfig,
MetadataProvider,
resolve_data_plane_client,
)


def _assert_writable_topology(stores: Sequence[MemoryStore], expect_extraction: bool = False) -> None:
"""Require at most one writable store, and optionally require one writer.

Args:
stores: AgentCore stores sharing one identity stream.
expect_extraction: Whether a writer is required.

Raises:
ValueError: If the topology would duplicate writes or cannot extract.
"""
# create_event writes to the identity stream rather than a namespace. Multiple
# writers would therefore duplicate the same conversation events.
writers = [store for store in stores if store.writable]
if len(writers) > 1:
names = ", ".join(f'"{store.name}"' for store in writers)
raise ValueError(
f"AgentCore memory: at most one store may be writable, but {len(writers)} are ({names}). "
"create_event is namespace-free, so multiple writable stores would write duplicate events to the "
"same (memory_id, actor_id, session_id) stream. Mark exactly one namespace writable."
)
if expect_extraction and not writers:
raise ValueError(
"AgentCore memory: extraction is enabled but no store is writable. Mark one namespace writable "
"(or omit extraction for recall-only)."
)


def create_agentcore_memory_stores(
*,
memory_id: str,
actor_id: str,
session_id: str,
namespaces: list[AgentCoreNamespaceConfig],
extraction: bool | AgentCoreExtractionConfig | None = None,
metadata_provider: MetadataProvider | None = None,
max_turns_per_event: int | None = None,
region_name: str | None = None,
boto3_session: boto3.Session | None = None,
client: AgentCoreClient | None = None,
) -> list[MemoryStore]:
"""Build one store per exact namespace with one shared boto3 client.

Args:
memory_id: AgentCore Memory resource identifier.
actor_id: Actor identifier.
session_id: Session identifier.
namespaces: Per-namespace store configuration dictionaries.
extraction: Recall-only switch, or custom cadence/filter configuration.
metadata_provider: Optional per-message event metadata callback.
max_turns_per_event: Maximum turns packed into one event.
region_name: Region used when constructing the shared client.
boto3_session: Session used when constructing the shared client.
client: Preconstructed AgentCore data-plane client, or this SDK's ``MemoryClient``,
shared by every store in the returned set.

Returns:
One store per namespace.

Raises:
ValueError: If namespace or writer configuration is invalid.
"""
if not isinstance(namespaces, list) or not namespaces:
raise ValueError("create_agentcore_memory_stores: at least one namespace is required")
for index, namespace_config in enumerate(namespaces):
namespace = namespace_config.get("namespace") if isinstance(namespace_config, dict) else None
if not isinstance(namespace, str) or not namespace.strip():
raise ValueError(
f"create_agentcore_memory_stores: namespaces[{index}].namespace must be a non-empty string"
)
if max_turns_per_event is not None and (type(max_turns_per_event) is not int or max_turns_per_event < 1):
raise ValueError(
f"create_agentcore_memory_stores: max_turns_per_event must be a positive integer, got {max_turns_per_event}"
)

# ``True`` leaves cadence to MemoryManager; only the object form builds a
# custom Strands extraction configuration.
write_enabled = extraction not in (None, False)
extraction_config: bool | ExtractionConfig | None
if not write_enabled:
extraction_config = None
elif isinstance(extraction, dict) and ("cadence" in extraction or "filter" in extraction):
extraction_config = ExtractionConfig()
cadence = extraction.get("cadence")
message_filter = extraction.get("filter")
if cadence is not None:
extraction_config["trigger"] = cadence
if message_filter is not None:
extraction_config["filter"] = message_filter
else:
extraction_config = True

# Build one connection and reuse it for every namespace in this identity set.
shared_client = (
resolve_data_plane_client(client)
if client is not None
else _create_data_plane_client(region_name=region_name, boto3_session=boto3_session)
)
# The default writer skips explicit opt-outs. Keep multiple explicit writers
# intact so the topology check fails loudly instead of silently choosing one.
any_flagged = any(config.get("writable") is True for config in namespaces)
default_writer_index = -1
if write_enabled and not any_flagged:
default_writer_index = next(
(index for index, config in enumerate(namespaces) if config.get("writable") is not False), -1
)
if write_enabled and not any_flagged and default_writer_index == -1:
raise ValueError(
"create_agentcore_memory_stores: extraction is enabled but every namespace is marked writable: false; "
"leave one namespace un-opted-out (or set writable: true on the intended writer)."
)

stores: list[AgentCoreMemoryStore] = []
for index, namespace_config in enumerate(namespaces):
is_writer = namespace_config.get("writable") is True or index == default_writer_index
stores.append(
AgentCoreMemoryStore(
memory_id=memory_id,
actor_id=actor_id,
session_id=session_id,
namespace=str(namespace_config["namespace"]),
name=namespace_config.get("name"),
description=namespace_config.get("description"),
max_search_results=namespace_config.get("max_search_results"),
min_score=namespace_config.get("min_score"),
over_fetch_factor=namespace_config.get("over_fetch_factor", 4),
writable=is_writer,
extraction=extraction_config if is_writer else None,
metadata_provider=metadata_provider,
max_turns_per_event=max_turns_per_event,
client=shared_client,
)
)
# MemoryManager validates store-name uniqueness, so only write topology is checked here.
_assert_writable_topology(stores, write_enabled)
return list(stores)
Loading
Loading