-
Notifications
You must be signed in to change notification settings - Fork 136
feat(memory): add AgentCoreMemoryStore Strands integration #588
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
strandly-the-agent
wants to merge
4
commits into
aws:main
Choose a base branch
from
strandly-the-agent:port/agentcore-memory-store-to-python
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ddd0ba5
feat(memory): add AgentCoreMemoryStore Strands integration
strandly 7bc3595
ci: leave integration-testing.yml untouched by the memorystore port
strandly-the-agent 692db12
Merge remote-tracking branch 'origin/main' into port/agentcore-memory…
strandly-the-agent 5c5c0b0
refactor(memory): build the store's client through the vended MemoryC…
strandly-the-agent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
139 changes: 139 additions & 0 deletions
139
src/bedrock_agentcore/memory/integrations/strands/memorystore/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
39 changes: 39 additions & 0 deletions
39
src/bedrock_agentcore/memory/integrations/strands/memorystore/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ] |
154 changes: 154 additions & 0 deletions
154
src/bedrock_agentcore/memory/integrations/strands/memorystore/factory.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| """Factory helpers for multi-namespace AgentCore memory topologies.""" | ||
|
|
||
| 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) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
port lgtm