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