diff --git a/README.md b/README.md
index aeacf78..c0e604f 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ flowchart LR
EB[EventBridge
Scheduler]
CRON[Cron Lambda]
RT[AgentCore Runtime
OpenClaw container linux/arm64
server.py memory hooks]
- MEM[(AgentCore Memory
sprout/{chat_id}/long_term
sprout/{chat_id}/episodic)]
+ MEM[(AgentCore Memory
sprout/{chat_id}/long_term
user-preference + semantic)]
S3[(S3 Workspace Store)]
BR[Bedrock Model
Claude Haiku 4.5]
@@ -103,30 +103,63 @@ one applies explains what the agent can recall — and how precisely.
|---|---|---|
| **What is written** | The conversation transcript | A record the app builds deliberately |
| **How facts appear** | Extraction strategies derive them asynchronously | Written synchronously, exactly as specified |
-| **Metadata** | Whatever extraction attaches | Explicit: `type`, `section`, `plants`, `photo_count` |
+| **Metadata** | Declared per strategy; extraction fills it in | Supplied explicitly by the app |
| **Used for** | Everyday chat (preferences, climate zone, plant talk) | Registering a named backyard section from a photo album |
-Every turn is persisted with `CreateEvent`, so the three configured strategies
-(user preference, semantic, summarization) keep deriving long-term records from
+Every turn is persisted with `CreateEvent`, so the two configured strategies
+(user preference and semantic) keep deriving long-term records from
ordinary conversation. On top of that, when you register a bed by captioning a
photo album, `server.py` writes a **structured** record so "what's in the north
bed" is a durable, queryable fact rather than something extraction may or may not
infer from chat text. A focused JSON-only extraction pass pulls the plant names
out of the agent's own description to populate the `plants` list.
+Both paths converge on the same metadata shape. Each strategy declares a
+`MemoryRecordSchema.MetadataSchema` in the template, where `Definition` and
+`LlmExtractionInstruction` are the instructions handed to the extraction model
+and `Validation.AllowedValues` constrains the output — so extracted records carry
+the same `type` / `section` / `plants` dimensions the app writes directly, with
+consistent values.
+
### Querying custom metadata
-Custom metadata is stored on the record and returned on retrieval, but
-`RetrieveMemoryRecords`' server-side `metadataFilters` only accepts reserved
-`x-amz-agentcore-memory-*` keys — a custom key such as `section` is rejected with
-`not a valid filter key`. Sprout therefore retrieves semantically (scoped to the
-user's namespace) and filters custom metadata **client-side**:
+Custom metadata keys are filterable **server-side**, but only when declared as
+`IndexedKeys` on the memory resource. AgentCore applies indexed-key filters
+*before* the vector search, so a filter narrows the candidate set rather than
+trimming whatever similarity happened to return:
```python
-# "which beds have basil?" — list-membership match, case-insensitive
+# "which beds have basil?" — CONTAINS on the plants STRINGLIST, pushed down
memory.retrieve(chat_id, "beds", metadata_filters={"plants": "basil"})
```
+Sprout indexes `type`, `section`, and `plants`. Filtering on a key that is *not*
+indexed raises `ValidationException` ("not a valid filter key"), so non-indexed
+metadata such as `photo_count` is still stored and returned on the record but is
+filtered in-process instead.
+
+> Indexed keys are **additive-only and cannot be removed** once added (max 10 per
+> memory resource), so add them deliberately. Reserve them for dimensions you
+> actually filter on and leave enrichment-only fields non-indexed.
+
+Temporal scoping is also available without spending an indexed-key slot: the
+service-generated `x-amz-agentcore-memory-createdAt` field is filterable with
+`BEFORE`/`AFTER`, exposed as `retrieve(..., created_after=...)`. Sprout does not
+apply it on the normal chat or reminder paths on purpose — the facts those paths
+need ("grows basil in containers", "zone 9b") are the *oldest* ones, and a recency
+bound would hide them. It is there for genuinely recency-scoped questions.
+
+### A note on redundancy
+
+Registering one album writes the deterministic section record **and** leaves the
+same turn to the semantic and user-preference strategies, so several records can
+describe the same bed. That is a deliberate trade: the direct write is synchronous
+and guaranteed (extraction is asynchronous and best-effort), which is what makes a
+just-registered bed immediately recallable. The cost is that those records share
+the capped retrieval budget, so the direct record's content is kept to the durable
+facts rather than embedding the agent's full reply. If you index many dimensions or
+register many sections, watch how much of the budget goes to duplicates.
+
## Estimated Monthly Cost
Costs assume light personal usage (a handful of conversations per day) in
diff --git a/agent-container/server.py b/agent-container/server.py
index b3678c3..fcfc571 100644
--- a/agent-container/server.py
+++ b/agent-container/server.py
@@ -39,7 +39,7 @@
Testability
-----------
The deterministic logic — namespace derivation (:func:`derive_long_term_namespace`,
-:func:`derive_episodic_namespace`) and memory-context assembly
+:func:`derive_long_term_namespace_path`) and memory-context assembly
(:func:`assemble_memory_context`) — is implemented as pure, module-level
functions with no I/O so they can be unit/property tested in isolation
(tasks 2.3, 2.4). All heavy/optional dependencies (``boto3``, ``requests``) are
@@ -87,9 +87,13 @@
DEFAULT_PORT = 8080
# --- Memory namespace convention (Req 6.1) ------------------------------------
+# Both extraction strategies (user-preference and semantic) write to
+# ``sprout/{chat_id}/long_term``. Retrieval uses ``namespacePath`` rather than
+# ``namespace`` because ``namespace`` matches only the EXACT value given: a strategy
+# nested one level deeper (a summarization strategy must end in ``{sessionId}``)
+# would be silently omitted. ``namespacePath`` returns the whole subtree.
NAMESPACE_ROOT = "sprout"
LONG_TERM_SEGMENT = "long_term"
-EPISODIC_SEGMENT = "episodic"
# --- Memory retrieval / assembly bounds ---------------------------------------
MAX_MEMORIES_IN_CONTEXT = 50 # Req 5.4
@@ -199,29 +203,43 @@ def derive_long_term_namespace(chat_id: str) -> str:
Args:
chat_id: The Telegram chat id used as the AgentCore ``actorId``.
+ Note:
+ This is the exact namespace both extraction strategies write to. It does
+ NOT reach deeper namespaces: verified against the service, querying
+ ``namespace="sprout/{id}/long_term"`` returns only records stored at
+ exactly that namespace, not ones nested beneath it. Retrieval therefore
+ uses :func:`derive_long_term_namespace_path` with the ``namespacePath``
+ parameter, which covers the whole subtree.
+
Returns:
The namespace ``sprout/{chat_id}/long_term``.
"""
return f"{NAMESPACE_ROOT}/{chat_id}/{LONG_TERM_SEGMENT}"
-def derive_episodic_namespace(chat_id: str, session_id: str) -> str:
- """Derive the episodic memory namespace for a chat id + session (Req 6.1).
+def derive_long_term_namespace_path(chat_id: str) -> str:
+ """Derive the hierarchical namespace path covering all of a user's memories.
+
+ Passed as ``namespacePath`` (not ``namespace``) to ``RetrieveMemoryRecords`` /
+ ``ListMemoryRecords`` to retrieve every record in the subtree. Today both
+ extraction strategies write directly to ``sprout/{chat_id}/long_term``, so this
+ is equivalent — but ``namespace`` matches only the exact value given (verified
+ against the service), so anything nested deeper would be silently omitted.
+ Using the path keeps retrieval correct if a nested strategy is ever added.
Args:
chat_id: The Telegram chat id used as the AgentCore ``actorId``.
- session_id: The per-invocation session identifier.
Returns:
- The namespace ``sprout/{chat_id}/episodic/{session_id}``.
+ The namespace path ``sprout/{chat_id}/long_term``.
"""
- return f"{NAMESPACE_ROOT}/{chat_id}/{EPISODIC_SEGMENT}/{session_id}"
+ return derive_long_term_namespace(chat_id)
def chat_id_from_namespace(namespace: str) -> str:
"""Extract the owning chat id from a Sprout namespace.
- Inverse of :func:`derive_long_term_namespace` / :func:`derive_episodic_namespace`
+ Inverse of :func:`derive_long_term_namespace`
used to enforce namespace isolation (Req 6.4).
Args:
@@ -791,21 +809,31 @@ class Confidence(str, Enum):
# "what's in the north bed" is a durable structured fact rather than
# something extraction may or may not derive from chat text.
#
-# IMPORTANT (verified against the API): custom metadata keys are stored on the
-# record and returned on retrieval, but RetrieveMemoryRecords' server-side
-# ``metadataFilters`` only accepts a small set of reserved
-# ``x-amz-agentcore-memory-*`` keys — a custom key such as ``section`` is
-# rejected with "not a valid filter key". So retrieval stays semantic (+ the
-# per-user namespace) and custom metadata is filtered CLIENT-SIDE via
-# :func:`filter_records_by_metadata`.
+# Custom metadata keys ARE filterable server-side, but only when declared as
+# ``IndexedKeys`` on the memory resource (see ``AgentCoreMemory`` in
+# openclaw-telegram.yaml). AgentCore pre-filters on indexed keys BEFORE the
+# vector search, so a filter shrinks the candidate set instead of trimming
+# whatever similarity happened to return — a section can otherwise be missed
+# entirely because it did not rank in the top-K. Filtering on a key that is NOT
+# indexed raises ValidationException ("not a valid filter key"); such keys are
+# still stored on the record and visible via Get/ListMemoryRecords, so they are
+# filtered in-process by :func:`filter_records_by_metadata` instead.
SECTION_RECORD_TYPE = "section"
-# Metadata keys written on a section record (custom, client-side filterable).
+# Metadata keys written on a section record.
META_TYPE = "type"
META_SECTION = "section"
META_PLANTS = "plants"
+# photo_count is deliberately NOT indexed — it enriches the record but is not a
+# dimension worth spending one of the 10 permanent indexed-key slots on.
META_PHOTO_COUNT = "photo_count"
+# Keys declared under ``IndexedKeys`` in the template, i.e. the ones that may be
+# used in a server-side ``metadataFilters`` expression. Keep in sync with the
+# template: indexed keys are additive-only and cannot be removed once added.
+INDEXED_METADATA_KEYS = frozenset({META_TYPE, META_SECTION, META_PLANTS})
# Reserved prefix AgentCore adds to its own metadata keys on stored records.
+# These are filterable without being declared (e.g. the createdAt timestamp).
RESERVED_METADATA_PREFIX = "x-amz-agentcore-memory-"
+RESERVED_CREATED_AT_KEY = "x-amz-agentcore-memory-createdAt"
def derive_section_slug(name: str) -> str:
@@ -946,6 +974,67 @@ def parse_extracted_plants(raw: str) -> list[str]:
return seen
+def build_event_metadata(values: dict[str, Any]) -> dict[str, dict[str, str]]:
+ """Build the ``CreateEvent`` metadata map for known-at-event-time values (pure).
+
+ Attaching metadata to the event is the preferred path for keys whose value the
+ application already knows when the turn happens (here: the backyard
+ ``section`` a photo album belongs to). AgentCore propagates it through
+ extraction, so the derived long-term records carry the same dimension instead
+ of depending on the model to re-infer it from prose.
+
+ ``CreateEvent`` metadata values are ``stringValue``-only (unlike
+ ``BatchCreateMemoryRecords``, which also accepts ``stringListValue`` /
+ ``numberValue``), so every value is coerced to a string and empty values are
+ dropped. List-valued dimensions such as ``plants`` cannot travel this path and
+ are supplied on the directly-written record instead.
+
+ Args:
+ values: Plain ``{key: value}`` pairs to attach to the event.
+
+ Returns:
+ The metadata map accepted by ``CreateEvent`` (empty when nothing applies).
+ """
+ out: dict[str, dict[str, str]] = {}
+ for key, value in (values or {}).items():
+ if value is None or isinstance(value, (list, tuple, dict)):
+ continue
+ text = str(value).strip()
+ if text:
+ out[key] = {"stringValue": text}
+ return out
+
+
+def build_section_summary(
+ *, section_name: str, plants: Optional[list[str]] = None, photo_count: int = 0
+) -> str:
+ """Build the record content for a registered section (pure).
+
+ Deliberately terse. Registering one album yields several records covering the
+ same ground — this deterministic one plus whatever the semantic and
+ user-preference strategies extract from the same turn — and all of them
+ compete for the same capped retrieval budget. Embedding the agent's full
+ conversational reply here made this the longest of those duplicates while
+ adding no fact the extracted records lack, so the content is reduced to the
+ durable facts: which bed, which plants, and how many photos it came from.
+
+ Args:
+ section_name: The section name as the gardener wrote it.
+ plants: Plant names identified in the section.
+ photo_count: How many photos the section was registered from.
+
+ Returns:
+ A single-sentence factual summary for the record's ``content.text``.
+ """
+ name = (section_name or "").strip() or "unnamed section"
+ parts = [f"Backyard section '{name}'"]
+ if plants:
+ parts.append("contains " + ", ".join(plants))
+ if photo_count:
+ parts.append(f"registered from {photo_count} photo(s)")
+ return "; ".join(parts) + "."
+
+
def build_section_record(
*,
chat_id: str,
@@ -991,13 +1080,76 @@ def build_section_record(
}
+def build_created_after_filter(moment: datetime) -> dict[str, Any]:
+ """Build an ``AFTER`` filter on the service-generated createdAt timestamp (pure).
+
+ The reserved ``x-amz-agentcore-memory-*`` timestamps are filterable WITHOUT
+ being declared as indexed keys, so temporal scoping costs none of the 10
+ permanent indexed-key slots. Useful for "what changed recently" style recall —
+ e.g. scoping a proactive nudge to the last few weeks of garden history.
+
+ Args:
+ moment: Return only records created strictly after this instant.
+
+ Returns:
+ A single ``metadataFilters`` entry.
+ """
+ return {
+ "left": {"metadataKey": RESERVED_CREATED_AT_KEY},
+ "operator": "AFTER",
+ "right": {"metadataValue": {"dateTimeValue": moment}},
+ }
+
+
+def build_metadata_filters(
+ filters: dict[str, Any],
+) -> tuple[list[dict[str, Any]], dict[str, Any]]:
+ """Split requested filters into server-side expressions and leftovers (pure).
+
+ Keys in :data:`INDEXED_METADATA_KEYS` become ``metadataFilters`` entries for
+ ``RetrieveMemoryRecords``, which AgentCore applies BEFORE the vector search.
+ Everything else is returned as a residual dict for in-process filtering by
+ :func:`filter_records_by_metadata`, because filtering on a non-indexed key
+ would raise ``ValidationException``.
+
+ A list/tuple value on a ``STRINGLIST`` key uses ``CONTAINS`` per element
+ (list membership, e.g. "which beds contain basil"); a scalar uses
+ ``EQUALS_TO`` on a ``STRING`` key and ``CONTAINS`` on ``plants``.
+
+ Args:
+ filters: Requested ``{key: expected}`` metadata pairs.
+
+ Returns:
+ A ``(server_filters, residual_filters)`` tuple.
+ """
+ server: list[dict[str, Any]] = []
+ residual: dict[str, Any] = {}
+ for key, expected in (filters or {}).items():
+ if key not in INDEXED_METADATA_KEYS:
+ residual[key] = expected
+ continue
+ # ``plants`` is a STRINGLIST: membership is expressed with CONTAINS.
+ operator = "CONTAINS" if key == META_PLANTS else "EQUALS_TO"
+ values = expected if isinstance(expected, (list, tuple)) else [expected]
+ for value in values:
+ server.append(
+ {
+ "left": {"metadataKey": key},
+ "operator": operator,
+ "right": {"metadataValue": {"stringValue": str(value)}},
+ }
+ )
+ return server, residual
+
+
def filter_records_by_metadata(
records: list["MemoryContextRecord"], filters: dict[str, Any]
) -> list["MemoryContextRecord"]:
- """Filter retrieved records by custom metadata, client-side (pure).
+ """Filter retrieved records by metadata in-process (pure).
- Server-side ``metadataFilters`` reject custom keys (see the module note
- above), so equality/membership filtering on our own metadata happens here.
+ Used for keys that are NOT declared as ``IndexedKeys`` and therefore cannot
+ appear in a server-side ``metadataFilters`` expression (see the module note
+ above); indexed keys are filtered by the service before the vector search.
A record matches when, for every ``(key, expected)`` pair, its metadata has
that key and either equals ``expected`` or — when the stored value is a list
— contains it. String comparison is case-insensitive.
@@ -1053,9 +1205,9 @@ class MemoryContextRecord:
topic: str = ""
record_id: str = ""
# Flattened custom metadata from the stored record (reserved
- # ``x-amz-agentcore-memory-*`` keys removed). Enables client-side filtering
- # (:func:`filter_records_by_metadata`) since the API rejects custom keys in
- # server-side metadataFilters.
+ # ``x-amz-agentcore-memory-*`` keys removed). Lets callers inspect metadata
+ # on returned records and filter non-indexed keys in-process
+ # (:func:`filter_records_by_metadata`).
metadata: dict[str, Any] = field(default_factory=dict)
@@ -1336,6 +1488,7 @@ def retrieve(
query: str,
*,
metadata_filters: Optional[dict[str, Any]] = None,
+ created_after: Optional[datetime] = None,
) -> list[MemoryContextRecord]:
"""Retrieve up to 50 relevant long-term memories (Req 5.1, 5.5).
@@ -1348,22 +1501,36 @@ def retrieve(
Args:
chat_id: The Telegram chat id (the authenticated actor).
query: The user's current message, used as the semantic search query.
- metadata_filters: Optional ``{key: expected}`` custom-metadata pairs
- (e.g. ``{"section": "north_bed"}``). Applied CLIENT-SIDE by
- :func:`filter_records_by_metadata` because the API rejects custom
- keys in server-side ``metadataFilters``.
+ metadata_filters: Optional ``{key: expected}`` metadata pairs (e.g.
+ ``{"section": "north_bed"}`` or ``{"plants": "basil"}``). Indexed
+ keys are pushed down as server-side ``metadataFilters`` and
+ applied BEFORE the vector search; any non-indexed key is applied
+ in-process afterwards (see :func:`build_metadata_filters`).
+ created_after: Optional lower bound on record creation time, pushed
+ down as an ``AFTER`` filter on the service-generated
+ ``x-amz-agentcore-memory-createdAt`` field. Reserved timestamps
+ are filterable without consuming an indexed-key slot.
Returns:
The normalized, retrieved records (unassembled), or ``[]`` on
timeout/error.
"""
- namespace = derive_long_term_namespace(chat_id)
+ # Use namespacePath (hierarchical), not namespace (exact): an exact query
+ # returns only records at precisely that namespace, silently omitting any
+ # stored deeper in the subtree.
+ namespace_path = derive_long_term_namespace_path(chat_id)
+ server_filters, residual_filters = build_metadata_filters(metadata_filters or {})
+ if created_after is not None:
+ server_filters.append(build_created_after_filter(created_after))
def _call() -> list[dict[str, Any]]:
+ search_criteria: dict[str, Any] = {"searchQuery": query}
+ if server_filters:
+ search_criteria["metadataFilters"] = server_filters
response = self._agentcore_client().retrieve_memory_records(
memoryId=self._memory_id,
- namespace=namespace,
- searchCriteria={"searchQuery": query},
+ namespacePath=namespace_path,
+ searchCriteria=search_criteria,
maxResults=MAX_MEMORIES_IN_CONTEXT,
)
if not isinstance(response, dict):
@@ -1397,9 +1564,17 @@ def _call() -> list[dict[str, Any]]:
executor.shutdown(wait=False)
normalized = [normalize_record(raw) for raw in raw_records if isinstance(raw, dict)]
- return filter_records_by_metadata(normalized, metadata_filters or {})
+ # Indexed keys were already applied by the service; only non-indexed
+ # keys remain to filter in-process.
+ return filter_records_by_metadata(normalized, residual_filters)
- def persist(self, chat_id: str, session_id: str, messages: list[dict[str, str]]) -> bool:
+ def persist(
+ self,
+ chat_id: str,
+ session_id: str,
+ messages: list[dict[str, str]],
+ metadata: Optional[dict[str, dict[str, str]]] = None,
+ ) -> bool:
"""Persist the session transcript via ``CreateEvent`` (Req 4.4, 4.6).
Keys the event by ``memoryId`` + ``actorId`` (the chat id) + ``sessionId``
@@ -1412,6 +1587,12 @@ def persist(self, chat_id: str, session_id: str, messages: list[dict[str, str]])
session_id: The per-invocation session id.
messages: Ordered ``{"role", "content"}`` turns (roles ``USER`` /
``ASSISTANT``).
+ metadata: Optional ``CreateEvent`` metadata map (see
+ :func:`build_event_metadata`) for dimensions known at event time,
+ e.g. the ``section`` a photo album belongs to. AgentCore
+ propagates it through extraction onto the derived records, so
+ metadata is attached on the event path too — not only on records
+ written directly via ``BatchCreateMemoryRecords``.
Returns:
``True`` when the event was created, ``False`` when persistence
@@ -1426,14 +1607,17 @@ def persist(self, chat_id: str, session_id: str, messages: list[dict[str, str]])
}
for message in messages
]
+ kwargs: dict[str, Any] = {
+ "memoryId": self._memory_id,
+ "actorId": chat_id,
+ "sessionId": session_id,
+ "eventTimestamp": datetime.now(timezone.utc),
+ "payload": payload,
+ }
+ if metadata:
+ kwargs["metadata"] = metadata
try:
- self._agentcore_client().create_event(
- memoryId=self._memory_id,
- actorId=chat_id,
- sessionId=session_id,
- eventTimestamp=datetime.now(timezone.utc),
- payload=payload,
- )
+ self._agentcore_client().create_event(**kwargs)
return True
except Exception: # noqa: BLE001 — non-fatal (Req 4.6).
logger.error(
@@ -1452,6 +1636,14 @@ def write_records(self, records: list[dict[str, Any]]) -> int:
structured record must never break the user's turn, since the
conversational transcript is still persisted.
+ Note:
+ ``memoryStrategyId`` is deliberately NOT set on these records. With it,
+ the service filters the supplied metadata down to that strategy's
+ schema and silently drops anything else — which would discard
+ ``photo_count`` (intentionally not part of any strategy schema).
+ Omitting it stores the metadata as supplied. The indexed keys still
+ behave identically for filtering either way.
+
Args:
records: Record dicts from :func:`build_section_record`.
@@ -2492,6 +2684,19 @@ def handle_invocation(self, payload: dict[str, Any]) -> dict[str, Any]:
# 7. Persist transcript (non-fatal, Req 4.4, 4.6). Persist the cleaned
# reply (directives removed) so memory extraction never sees the tags.
+ # Attach known-at-event-time metadata (the section a captioned album
+ # belongs to) so extraction propagates the same dimension onto the
+ # records it derives, rather than re-inferring it from prose.
+ event_metadata = (
+ build_event_metadata(
+ {
+ META_TYPE: SECTION_RECORD_TYPE,
+ META_SECTION: derive_section_slug(album_section_caption),
+ }
+ )
+ if album_section_caption
+ else {}
+ )
persisted = self._memory.persist(
chat_id,
session_id,
@@ -2499,6 +2704,7 @@ def handle_invocation(self, payload: dict[str, Any]) -> dict[str, Any]:
{"role": "USER", "content": message},
{"role": "ASSISTANT", "content": response_text},
],
+ metadata=event_metadata or None,
)
# 7b. Register a named backyard section as a STRUCTURED record when the
@@ -2517,9 +2723,10 @@ def handle_invocation(self, payload: dict[str, Any]) -> dict[str, Any]:
build_section_record(
chat_id=chat_id,
section_name=album_section_caption,
- summary=(
- f"Backyard section '{album_section_caption}' "
- f"(registered from {len(images)} photo(s)): {response_text}"
+ summary=build_section_summary(
+ section_name=album_section_caption,
+ plants=plants,
+ photo_count=len(images),
),
plants=plants,
photo_count=len(images),
diff --git a/agent-container/tests/test_album.py b/agent-container/tests/test_album.py
index 54d586e..869707c 100644
--- a/agent-container/tests/test_album.py
+++ b/agent-container/tests/test_album.py
@@ -154,7 +154,7 @@ def __init__(self):
def retrieve(self, chat_id, query, *, metadata_filters=None):
return []
- def persist(self, chat_id, session_id, messages):
+ def persist(self, chat_id, session_id, messages, metadata=None):
self.persisted = messages
return True
diff --git a/agent-container/tests/test_cron_nudge.py b/agent-container/tests/test_cron_nudge.py
index 50dc590..7510326 100644
--- a/agent-container/tests/test_cron_nudge.py
+++ b/agent-container/tests/test_cron_nudge.py
@@ -44,7 +44,7 @@ def retrieve(self, chat_id, query):
self.retrieve_args = (chat_id, query)
return list(self._records)
- def persist(self, chat_id, session_id, messages):
+ def persist(self, chat_id, session_id, messages, metadata=None):
self.calls.append("persist")
return True
diff --git a/agent-container/tests/test_memory_metadata.py b/agent-container/tests/test_memory_metadata.py
index fe01339..1542677 100644
--- a/agent-container/tests/test_memory_metadata.py
+++ b/agent-container/tests/test_memory_metadata.py
@@ -4,11 +4,13 @@
AgentCore Memory records (``BatchCreateMemoryRecords``) with queryable metadata,
rather than relying only on asynchronous extraction from chat text.
-IMPORTANT (verified against the live API): custom metadata keys are stored on the
-record and returned on retrieval, but ``RetrieveMemoryRecords``' server-side
-``metadataFilters`` rejects them ("not a valid filter key") — only reserved
-``x-amz-agentcore-memory-*`` keys are accepted. So custom-metadata filtering is
-client-side, which these tests pin down.
+IMPORTANT (verified against the live service): custom metadata keys ARE filterable
+server-side, but only when declared as ``IndexedKeys`` on the memory resource —
+AgentCore then applies them BEFORE the vector search. Filtering on a key that is
+not indexed raises ``ValidationException`` ("not a valid filter key"); such keys
+are still stored and returned, so they are filtered in-process instead. These
+tests pin down that split so filters are not silently stopped from being pushed
+down (which would let a record be missed because it did not rank in the top-K).
Covers the pure builders (:func:`server.derive_section_slug`,
:func:`server.build_metadata_map`, :func:`server.flatten_metadata`,
@@ -134,7 +136,7 @@ def test_build_section_record_unnamed_fallback():
# =============================================================================
-# filter_records_by_metadata (client-side; API rejects custom filter keys)
+# filter_records_by_metadata (in-process; for keys that are NOT indexed)
# =============================================================================
def _rec(content, **metadata):
return MemoryContextRecord(
@@ -233,40 +235,118 @@ def test_write_records_noop_on_empty():
# =============================================================================
-# retrieve() applies custom-metadata filters client-side
+# retrieve(): indexed keys filter server-side, others in-process
# =============================================================================
class _RetrieveClient:
+ """Fake that honors ``metadataFilters`` the way the service does.
+
+ The real service pre-filters on indexed keys BEFORE the vector search, so a
+ filtered request must come back already narrowed — this fake reproduces that
+ so the test would fail if we silently stopped pushing filters down.
+ """
+
+ _RECORDS = [
+ {
+ "content": {"text": "north bed: basil"},
+ "metadata": {
+ "section": {"stringValue": "north_bed"},
+ "plants": {"stringListValue": ["basil", "tomato"]},
+ "photo_count": {"numberValue": 2.0},
+ },
+ },
+ {
+ "content": {"text": "south bed: roses"},
+ "metadata": {
+ "section": {"stringValue": "south_bed"},
+ "plants": {"stringListValue": ["rose"]},
+ "photo_count": {"numberValue": 5.0},
+ },
+ },
+ ]
+
def retrieve_memory_records(self, **kwargs):
self.last_kwargs = kwargs
- return {
- "memoryRecordSummaries": [
- {
- "content": {"text": "north bed: basil"},
- "metadata": {"section": {"stringValue": "north_bed"}},
- },
- {
- "content": {"text": "south bed: roses"},
- "metadata": {"section": {"stringValue": "south_bed"}},
- },
- ]
+ out = []
+ for rec in self._RECORDS:
+ keep = True
+ for f in kwargs["searchCriteria"].get("metadataFilters", []):
+ key = f["left"]["metadataKey"]
+ want = f["right"]["metadataValue"]["stringValue"]
+ wrapped = rec["metadata"].get(key, {})
+ actual = wrapped.get("stringValue", wrapped.get("stringListValue"))
+ if f["operator"] == "EQUALS_TO" and actual != want:
+ keep = False
+ elif f["operator"] == "CONTAINS" and want not in (actual or []):
+ keep = False
+ if keep:
+ out.append(rec)
+ return {"memoryRecordSummaries": out}
+
+
+def test_retrieve_pushes_indexed_keys_down_as_server_side_filters():
+ client = _RetrieveClient()
+ mem = SproutMemory(memory_id="mem-1", client=client)
+
+ assert len(mem.retrieve("12345", "beds")) == 2
+ # No filters requested -> no metadataFilters in the request at all.
+ assert "metadataFilters" not in client.last_kwargs["searchCriteria"]
+
+ filtered = mem.retrieve("12345", "beds", metadata_filters={"section": "north_bed"})
+ assert [r.content for r in filtered] == ["north bed: basil"]
+
+ sent = client.last_kwargs["searchCriteria"]["metadataFilters"]
+ assert sent == [
+ {
+ "left": {"metadataKey": "section"},
+ "operator": "EQUALS_TO",
+ "right": {"metadataValue": {"stringValue": "north_bed"}},
}
+ ]
-def test_retrieve_filters_custom_metadata_client_side():
+def test_retrieve_uses_contains_for_stringlist_plants():
client = _RetrieveClient()
mem = SproutMemory(memory_id="mem-1", client=client)
- all_records = mem.retrieve("12345", "beds")
- assert len(all_records) == 2
+ filtered = mem.retrieve("12345", "beds", metadata_filters={"plants": "basil"})
- filtered = mem.retrieve("12345", "beds", metadata_filters={"section": "north_bed"})
assert [r.content for r in filtered] == ["north bed: basil"]
+ assert client.last_kwargs["searchCriteria"]["metadataFilters"][0]["operator"] == "CONTAINS"
+
+
+def test_retrieve_filters_non_indexed_key_in_process():
+ client = _RetrieveClient()
+ mem = SproutMemory(memory_id="mem-1", client=client)
- # Custom keys must NOT be sent as server-side metadataFilters (the API
- # rejects them); the request carries only the semantic search query.
+ # photo_count is NOT an indexed key: it must never be sent to the service
+ # (that would raise ValidationException) and is applied in-process instead.
+ filtered = mem.retrieve("12345", "beds", metadata_filters={"photo_count": 5.0})
+
+ assert [r.content for r in filtered] == ["south bed: roses"]
assert "metadataFilters" not in client.last_kwargs["searchCriteria"]
+def test_build_metadata_filters_splits_indexed_from_residual():
+ pushed, residual = server.build_metadata_filters(
+ {"section": "north_bed", "photo_count": 2}
+ )
+
+ assert [f["left"]["metadataKey"] for f in pushed] == ["section"]
+ assert residual == {"photo_count": 2}
+
+
+def test_build_metadata_filters_expands_list_values():
+ pushed, residual = server.build_metadata_filters({"plants": ["basil", "mint"]})
+
+ assert [f["right"]["metadataValue"]["stringValue"] for f in pushed] == ["basil", "mint"]
+ assert all(f["operator"] == "CONTAINS" for f in pushed)
+ assert residual == {}
+
+
+def test_build_metadata_filters_empty_is_noop():
+ assert server.build_metadata_filters({}) == ([], {})
+
+
# =============================================================================
# Album -> section registration through handle_invocation
# =============================================================================
@@ -311,7 +391,7 @@ def __init__(self):
def retrieve(self, chat_id, query, *, metadata_filters=None):
return []
- def persist(self, chat_id, session_id, messages):
+ def persist(self, chat_id, session_id, messages, metadata=None):
return True
def write_records(self, records):
@@ -519,3 +599,212 @@ def test_plants_metadata_is_filterable_client_side():
]
out = filter_records_by_metadata(records, {"plants": "Basil"})
assert [r.content for r in out] == ["north"]
+
+
+# =============================================================================
+# CreateEvent metadata: dimensions known at event time travel the event path too
+# =============================================================================
+from server import build_event_metadata # noqa: E402
+
+
+def test_build_event_metadata_stringvalue_only():
+ # CreateEvent metadata is stringValue-only; numbers are coerced, and
+ # list-valued dimensions (plants) cannot travel this path at all.
+ out = build_event_metadata({"section": "north_bed", "photo_count": 3, "plants": ["basil"]})
+ assert out == {
+ "section": {"stringValue": "north_bed"},
+ "photo_count": {"stringValue": "3"},
+ }
+
+
+def test_build_event_metadata_drops_empty():
+ assert build_event_metadata({"a": None, "b": "", "c": " ", "d": "keep"}) == {
+ "d": {"stringValue": "keep"}
+ }
+
+
+class _EventClient:
+ def __init__(self):
+ self.kwargs = None
+
+ def create_event(self, **kwargs):
+ self.kwargs = kwargs
+ return {}
+
+
+def test_persist_sends_metadata_on_create_event():
+ client = _EventClient()
+ mem = SproutMemory(memory_id="mem-1", client=client)
+
+ ok = mem.persist(
+ "12345",
+ "sess-1",
+ [{"role": "USER", "content": "hi"}],
+ metadata={"section": {"stringValue": "north_bed"}},
+ )
+
+ assert ok is True
+ assert client.kwargs["metadata"] == {"section": {"stringValue": "north_bed"}}
+
+
+def test_persist_omits_metadata_key_when_none():
+ client = _EventClient()
+ mem = SproutMemory(memory_id="mem-1", client=client)
+
+ mem.persist("12345", "sess-1", [{"role": "USER", "content": "hi"}])
+
+ # Never send an empty metadata map — omit the parameter entirely.
+ assert "metadata" not in client.kwargs
+
+
+class _MetadataCapturingMemory(_RecordingMemory):
+ def __init__(self):
+ super().__init__()
+ self.persist_metadata = "unset"
+
+ def persist(self, chat_id, session_id, messages, metadata=None):
+ self.persist_metadata = metadata
+ return True
+
+
+def test_captioned_album_attaches_section_metadata_to_the_event():
+ memory = _MetadataCapturingMemory()
+ runtime = server.SproutRuntime(
+ memory=memory,
+ workspace=_StubWorkspace(),
+ agent=_StubAgent(),
+ base_persona="You are Sprout.",
+ model_id="model",
+ album_buffer=server.AlbumBuffer("bucket", client=_FakeS3(), debounce_seconds=0),
+ )
+
+ runtime.handle_invocation(_album_payload("gev1"))
+
+ # The section is known at event time, so it rides the CreateEvent path and
+ # extraction propagates it onto the records it derives.
+ assert memory.persist_metadata == {
+ "type": {"stringValue": "section"},
+ "section": {"stringValue": "north_bed"},
+ }
+
+
+def test_normal_turn_sends_no_event_metadata():
+ memory = _MetadataCapturingMemory()
+ runtime = server.SproutRuntime(
+ memory=memory,
+ workspace=_StubWorkspace(),
+ agent=_StubAgent(),
+ base_persona="You are Sprout.",
+ model_id="model",
+ )
+
+ runtime.handle_invocation({"user_id": "12345", "message": "hello"})
+
+ assert memory.persist_metadata is None
+
+
+# =============================================================================
+# Temporal filtering on the reserved createdAt field
+# =============================================================================
+def test_build_created_after_filter_uses_reserved_key():
+ moment = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
+
+ f = server.build_created_after_filter(moment)
+
+ assert f == {
+ "left": {"metadataKey": "x-amz-agentcore-memory-createdAt"},
+ "operator": "AFTER",
+ "right": {"metadataValue": {"dateTimeValue": moment}},
+ }
+
+
+def test_retrieve_pushes_created_after_down():
+ client = _RetrieveClient()
+ mem = SproutMemory(memory_id="mem-1", client=client)
+ moment = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
+
+ mem.retrieve("12345", "beds", created_after=moment)
+
+ sent = client.last_kwargs["searchCriteria"]["metadataFilters"]
+ assert sent == [server.build_created_after_filter(moment)]
+
+
+def test_retrieve_combines_indexed_and_temporal_filters():
+ client = _RetrieveClient()
+ mem = SproutMemory(memory_id="mem-1", client=client)
+ moment = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
+
+ mem.retrieve(
+ "12345", "beds", metadata_filters={"section": "north_bed"}, created_after=moment
+ )
+
+ keys = [f["left"]["metadataKey"] for f in client.last_kwargs["searchCriteria"]["metadataFilters"]]
+ assert keys == ["section", "x-amz-agentcore-memory-createdAt"]
+
+
+# =============================================================================
+# build_section_summary: terse content (several records cover the same fact)
+# =============================================================================
+def test_section_summary_is_terse_and_factual():
+ s = server.build_section_summary(
+ section_name="Herb bed", plants=["basil", "mint"], photo_count=2
+ )
+ assert s == "Backyard section 'Herb bed'; contains basil, mint; registered from 2 photo(s)."
+
+
+def test_section_summary_omits_missing_parts():
+ assert server.build_section_summary(section_name="North bed") == "Backyard section 'North bed'."
+
+
+def test_section_summary_handles_blank_name():
+ assert server.build_section_summary(section_name=" ").startswith("Backyard section 'unnamed section'")
+
+
+def test_section_record_content_excludes_the_agent_reply():
+ memory = _RecordingMemory()
+ runtime = server.SproutRuntime(
+ memory=memory,
+ workspace=_StubWorkspace(),
+ agent=_PlantAgent(["basil", "mint"]),
+ base_persona="You are Sprout.",
+ model_id="model",
+ album_buffer=server.AlbumBuffer("bucket", client=_FakeS3(), debounce_seconds=0),
+ )
+
+ runtime.handle_invocation(_album_payload("gsum1"))
+
+ text = memory.written[0]["content"]["text"]
+ # The conversational reply must not be embedded — extraction already stores
+ # its own (tighter) version of the same fact.
+ assert "Lovely bed!" not in text
+ assert text == "Backyard section 'North bed'; contains basil, mint; registered from 1 photo(s)."
+
+
+# =============================================================================
+# Namespace vs namespacePath: summaries live one level deeper
+# =============================================================================
+def test_retrieve_uses_namespacepath_not_exact_namespace():
+ """Regression guard for a bug we shipped and had to correct.
+
+ ``namespace`` matches ONLY the exact namespace given (verified against the
+ service), so querying it omits session summaries stored at
+ ``sprout/{chat_id}/long_term/{sessionId}`` — leaving the summarization
+ strategy write-only, the very problem moving it was meant to fix.
+ ``namespacePath`` retrieves the whole subtree.
+ """
+ client = _RetrieveClient()
+ mem = SproutMemory(memory_id="mem-1", client=client)
+
+ mem.retrieve("12345", "beds")
+
+ assert client.last_kwargs["namespacePath"] == "sprout/12345/long_term"
+ assert "namespace" not in client.last_kwargs
+
+
+def test_long_term_namespace_path_matches_the_strategy_namespace():
+ # Both extraction strategies write to exactly this namespace; the path form is
+ # what retrieval uses so a nested strategy would also be covered.
+ assert server.derive_long_term_namespace_path("12345") == "sprout/12345/long_term"
+ assert server.derive_long_term_namespace_path("12345") == server.derive_long_term_namespace(
+ "12345"
+ )
diff --git a/agent-container/tests/test_scheduler.py b/agent-container/tests/test_scheduler.py
index 6e9ab23..7c31663 100644
--- a/agent-container/tests/test_scheduler.py
+++ b/agent-container/tests/test_scheduler.py
@@ -309,7 +309,7 @@ def __init__(self, records=None):
def retrieve(self, chat_id, query):
return list(self._records)
- def persist(self, chat_id, session_id, messages):
+ def persist(self, chat_id, session_id, messages, metadata=None):
self.persisted = messages
return True
diff --git a/agent-container/tests/test_server.py b/agent-container/tests/test_server.py
index 006e958..5080fcf 100644
--- a/agent-container/tests/test_server.py
+++ b/agent-container/tests/test_server.py
@@ -62,7 +62,7 @@ def retrieve(self, chat_id, query):
self.retrieve_args = (chat_id, query)
return list(self._records)
- def persist(self, chat_id, session_id, messages):
+ def persist(self, chat_id, session_id, messages, metadata=None):
self.calls.append("persist")
self.persist_args = (chat_id, session_id, messages)
return self._persist_result
@@ -478,8 +478,11 @@ def retrieve_memory_records(self, **kwargs):
assert len(records) == 1
assert records[0].content == "Grows tomatoes"
assert records[0].confidence_class is Confidence.EXPLICIT
- # Retrieval is scoped to the user's long-term namespace and the query.
- assert client.kwargs["namespace"] == "sprout/chat-1/long_term"
+ # Retrieval is scoped to the user's long-term subtree via namespacePath (not
+ # the exact `namespace`, which would omit session summaries stored one level
+ # deeper at sprout/{chat_id}/long_term/{sessionId}).
+ assert client.kwargs["namespacePath"] == "sprout/chat-1/long_term"
+ assert "namespace" not in client.kwargs
assert client.kwargs["searchCriteria"] == {"searchQuery": "tomato care"}
assert client.kwargs["maxResults"] == server.MAX_MEMORIES_IN_CONTEXT
diff --git a/docs/configuration.md b/docs/configuration.md
index 2dc8c93..3ddb787 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -47,7 +47,7 @@ Set by the `EnvironmentVariables` block of the `AgentCoreRuntime` resource in
| --- | --- | --- |
| `MODEL_ID` | `ModelId` parameter | Bedrock model / cross-region inference profile the agent and prompt-cached Converse calls use for text. Read by `server.py` at invocation time. |
| `VISION_MODEL_ID` | `VisionModelId` parameter | Bedrock model used for plant image identification (vision). Read by `server.py` when an invocation includes images. |
-| `MEMORY_ID` | `AgentCoreMemory.MemoryId` | AgentCore Memory resource ID targeted by all `RetrieveMemoryRecords` and `CreateEvent` data-plane calls. |
+| `MEMORY_ID` | `AgentCoreMemory.MemoryId` | AgentCore Memory resource ID targeted by all data-plane calls — `RetrieveMemoryRecords`, `CreateEvent`, and `BatchCreateMemoryRecords`. |
| `WORKSPACE_BUCKET` | `WorkspaceBucket` | S3 bucket used to persist the OpenClaw workspace between container freezes. When unset, workspace persistence becomes a no-op. |
Additional runtime tuning variables read by `server.py` (not set by the template today —
@@ -71,6 +71,73 @@ resources:
| `BOT_TOKEN_SECRET_ARN` | `BotTokenSecret` | Secrets Manager ARN the Lambda reads (uncached, always latest) to obtain the Telegram bot token. |
| `TELEGRAM_API_BASE` | Literal `https://api.telegram.org` | Base URL for Telegram Bot API calls (`sendMessage`, `getFile`, etc.). |
+## Memory configuration
+
+The `AgentCoreMemory` resource in the template is configured in three parts —
+namespaces, indexed keys, and the per-strategy metadata schema — all of which affect
+what the agent can recall and how precisely.
+
+### Namespaces
+
+Both extraction strategies write to the same per-user namespace:
+
+| Strategy | Namespace |
+| --- | --- |
+| `UserPreferenceMemoryStrategy` | `sprout/{actorId}/long_term` |
+| `SemanticMemoryStrategy` | `sprout/{actorId}/long_term` |
+
+The runtime retrieves with the **`namespacePath`** parameter rather than
+`namespace`. Today the two are equivalent, since both strategies write directly to
+that namespace — but `namespace` matches only the *exact* value given, so anything
+stored deeper in the subtree would be silently omitted. Using the path form keeps
+retrieval correct if a nested strategy is added later.
+
+> There is deliberately **no** `SummaryMemoryStrategy`. Session summaries restated,
+> in looser prose, facts the semantic and user-preference strategies already
+> extract, while costing extraction on every session and accumulating one record per
+> session that competes for the capped retrieval budget. If you add one back, its
+> namespace **must** end in `{sessionId}` — the service rejects a summarization
+> namespace without it — which is exactly the nested case `namespacePath` handles.
+
+### Indexed keys
+
+`IndexedKeys` declares which metadata keys may be used in a server-side
+`metadataFilters` expression. Sprout indexes:
+
+| Key | Type | Used for |
+| --- | --- | --- |
+| `type` | `STRING` | Separating sections, plants, conditions, and care |
+| `section` | `STRING` | Scoping retrieval to one backyard area |
+| `plants` | `STRINGLIST` | `CONTAINS` lookups such as "which beds have basil?" |
+
+Filters on indexed keys are applied **before** the vector search, so they narrow the
+candidate set rather than trimming whatever similarity returned. Filtering on a
+non-indexed key raises `ValidationException`; such metadata (for example
+`photo_count`) is still stored on the record and returned by
+`Get`/`ListMemoryRecords`, and `server.py` filters it in-process instead.
+
+> Indexed keys are **additive-only and cannot be removed** once added, with a limit
+> of 10 per memory resource. Add them deliberately, and reserve them for dimensions
+> you actually filter on.
+
+### Metadata schema (extraction instructions)
+
+Each strategy declares a `MemoryRecordSchema.MetadataSchema`, which is how the
+extraction model is instructed:
+
+- `Definition` — what the field means (this is the primary instruction).
+- `LlmExtractionInstruction` — extra guidance and conflict resolution; the built-in
+ `LATEST_VALUE` keeps the most recent value when events disagree.
+- `Validation.AllowedValues` / `MaxItems` — constrains the output so filter values
+ stay consistent (without it the model may emit `Herbs`, `herbs`, and `HERB` for
+ one concept and break downstream matching).
+
+Keys whose values the application already knows are also attached to the event via
+`CreateEvent` metadata (see `build_event_metadata` in `server.py`), so extraction
+propagates them onto derived records instead of re-inferring them from prose. Note
+that `CreateEvent` metadata is `stringValue`-only, so list-valued dimensions such as
+`plants` are supplied on records written directly with `BatchCreateMemoryRecords`.
+
## Model switching
The model is fully parameterized, so you can switch it without rebuilding or re-pushing
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 290a857..3c6f45e 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -128,7 +128,7 @@ Each threat is rated for pre-mitigation severity and marked **Mitigated**,
| ID | Threat | Severity | Mitigation | Status |
|----|--------|----------|------------|--------|
-| I-1 | Cross-user memory/data leakage | High | Memory is namespaced `sprout/{chat_id}/long_term` and `.../episodic/{sessionId}`; retrieval is scoped to the caller's namespace; S3 workspace prefix is `workspace/{chat_id}/`. `derive_*_namespace` embeds the chat id as the sole variable segment so no two users collide. | Mitigated |
+| I-1 | Cross-user memory/data leakage | High | Memory is namespaced `sprout/{chat_id}/long_term`; retrieval is scoped to the caller's own subtree via `namespacePath` (`sprout/{chat_id}/long_term`), which cannot reach another chat id's subtree; S3 workspace prefix is `workspace/{chat_id}/`. `derive_*_namespace` embeds the chat id as the sole variable segment so no two users collide. | Mitigated |
| I-2 | Secrets leaked in logs, source, or history | High | Secret-history scan of the repo is clean; `.env` is gitignored and never tracked; token read from Secrets Manager at runtime, never echoed; `NoEcho: true` on the CFN token parameter. | Mitigated |
| I-3 | S3 bucket public exposure | High | `PublicAccessBlockConfiguration` all four flags true; bucket policy grants only the runtime role; TLS enforced. | Mitigated |
| I-4 | Overly broad IAM enabling data access beyond need | Medium | Each role (runtime, memory, webhook, cron, scheduler) has a dedicated least-privilege policy scoped to account/region-qualified ARNs. `bedrock:InvokeModel` on `foundation-model/*` is required for cross-region inference profiles (documented, see §7). | Partial |
diff --git a/openclaw-telegram.yaml b/openclaw-telegram.yaml
index 1ef019f..5210219 100644
--- a/openclaw-telegram.yaml
+++ b/openclaw-telegram.yaml
@@ -625,8 +625,10 @@ Resources:
# AgentCore Memory provides cross-session recall. The managed extraction
# strategies asynchronously derive long-term records from session events.
# - USER_PREFERENCE -> explicit user preferences -> sprout/{actorId}/long_term
- # - SEMANTIC -> inferred factual knowledge -> sprout/{actorId}/long_term
- # - SUMMARIZATION -> episodic session summaries -> sprout/{actorId}/episodic/{sessionId}
+ # - SEMANTIC -> inferred factual knowledge -> sprout/{actorId}/long_term
+ # Both write to sprout/{actorId}/long_term, which the runtime retrieves with the
+ # hierarchical `namespacePath` parameter (so any future strategy nested under it
+ # is included without a code change).
# Memory resource names must match [a-zA-Z][a-zA-Z0-9_]{0,47} (no hyphens),
# so a static underscore-free name is used rather than the stack name.
AgentCoreMemory:
@@ -636,22 +638,133 @@ Resources:
Description: Cross-session memory for the Sprout gardening assistant
EventExpiryDuration: 30
MemoryExecutionRoleArn: !GetAtt MemoryExecutionRole.Arn
+ # Metadata keys indexed for server-side filtering on RetrieveMemoryRecords /
+ # ListMemoryRecords. ONLY indexed keys are filterable — filtering on a
+ # non-indexed key raises ValidationException ("not a valid filter key").
+ # Non-indexed metadata (e.g. photo_count) is still stored on records and
+ # returned by Get/ListMemoryRecords; it just cannot appear in a filter.
+ #
+ # IMPORTANT: indexed keys are ADDITIVE-ONLY and cannot be removed once
+ # added (max 10 per memory). Keep this list to the dimensions actually
+ # filtered on, and add new ones deliberately rather than speculatively.
+ IndexedKeys:
+ - Key: type
+ Type: STRING
+ - Key: section
+ Type: STRING
+ - Key: plants
+ Type: STRINGLIST
MemoryStrategies:
+ # Each strategy declares a MetadataSchema so extraction populates the
+ # same dimensions the app writes directly via BatchCreateMemoryRecords.
+ # Definition + LlmExtractionInstruction ARE the instructions given to the
+ # extraction model (this replaces reaching for an AppendToPrompt
+ # override), and Validation constrains the output so filter values stay
+ # consistent — without it the model may emit "Herbs"/"herbs"/"HERB" for
+ # one concept and break downstream matching.
- UserPreferenceMemoryStrategy:
Name: SproutUserPreference
Description: Extracts explicit user gardening preferences
Namespaces:
- "sprout/{actorId}/long_term"
+ MemoryRecordSchema:
+ MetadataSchema:
+ - Key: type
+ Type: STRING
+ ExtractionConfig:
+ LlmExtractionConfig:
+ Definition: >-
+ The kind of gardening fact this record captures. Use
+ 'preference' for how the gardener likes to garden
+ (organic vs synthetic, containers vs in-ground, watering
+ style), 'section' for a named bed or area of the
+ backyard, and 'plant' for a fact about a specific plant.
+ LlmExtractionInstruction: LATEST_VALUE
+ Validation:
+ StringValidation:
+ AllowedValues:
+ - preference
+ - section
+ - plant
+ - Key: section
+ Type: STRING
+ ExtractionConfig:
+ LlmExtractionConfig:
+ Definition: >-
+ The named area of the backyard this fact belongs to, as
+ the gardener refers to it (for example 'north_bed',
+ 'herb_bed', 'front_planters'). Normalize to lowercase
+ with underscores. Omit when the fact is not tied to a
+ specific area.
+ LlmExtractionInstruction: LATEST_VALUE
+ - Key: plants
+ Type: STRINGLIST
+ ExtractionConfig:
+ LlmExtractionConfig:
+ Definition: >-
+ Common names of the plants this fact concerns, lowercase
+ and singular (for example 'basil', 'tomato', 'mint').
+ Include only plants the gardener actually grows or asked
+ about; omit soil, tools, and containers.
+ Validation:
+ StringListValidation:
+ MaxItems: 5
- SemanticMemoryStrategy:
Name: SproutSemantic
Description: Extracts inferred factual knowledge about the user's garden
Namespaces:
- "sprout/{actorId}/long_term"
- - SummaryMemoryStrategy:
- Name: SproutSummarization
- Description: Condenses session transcripts into episodic summaries
- Namespaces:
- - "sprout/{actorId}/episodic/{sessionId}"
+ MemoryRecordSchema:
+ MetadataSchema:
+ - Key: type
+ Type: STRING
+ ExtractionConfig:
+ LlmExtractionConfig:
+ Definition: >-
+ The kind of gardening fact this record captures: 'section'
+ for a named bed or area of the backyard, 'plant' for a
+ fact about a specific plant, 'condition' for climate,
+ sun, soil, or drainage, and 'care' for a watering,
+ feeding, or pruning routine.
+ LlmExtractionInstruction: LATEST_VALUE
+ Validation:
+ StringValidation:
+ AllowedValues:
+ - section
+ - plant
+ - condition
+ - care
+ - Key: section
+ Type: STRING
+ ExtractionConfig:
+ LlmExtractionConfig:
+ Definition: >-
+ The named area of the backyard this fact belongs to, as
+ the gardener refers to it (for example 'north_bed',
+ 'herb_bed', 'front_planters'). Normalize to lowercase
+ with underscores. Omit when the fact is not tied to a
+ specific area.
+ LlmExtractionInstruction: LATEST_VALUE
+ - Key: plants
+ Type: STRINGLIST
+ ExtractionConfig:
+ LlmExtractionConfig:
+ Definition: >-
+ Common names of the plants this fact concerns, lowercase
+ and singular (for example 'basil', 'tomato', 'mint').
+ Include only plants the gardener actually grows or asked
+ about; omit soil, tools, and containers.
+ Validation:
+ StringListValidation:
+ MaxItems: 5
+ # NOTE: there is deliberately no SummaryMemoryStrategy. Session summaries
+ # duplicated, in looser prose, facts the semantic and user-preference
+ # strategies already extract — while costing extraction on every session
+ # and competing for the capped retrieval budget (summaries accumulate one
+ # per session indefinitely). If you add one back: its namespace MUST end
+ # in {sessionId} (the service rejects it otherwise), which is why
+ # retrieval below uses the hierarchical `namespacePath` — an exact
+ # `namespace` query would not return records nested under it.
# ----------------------------------------------------------------------------
# AgentCore Runtime