From f79157c8206c90baba1a4755b2bfb02014d905b5 Mon Sep 17 00:00:00 2001 From: Thiago Verney Date: Fri, 31 Jul 2026 13:25:45 -0500 Subject: [PATCH] Enable server-side metadata filtering on AgentCore Memory records Garden sections were stored with custom metadata that could not be used to filter retrieval, so "which beds have basil?" fell back to similarity search over prose. This makes those dimensions queryable and corrects several claims in the code and docs that turned out to be wrong. Template (AgentCoreMemory): - Declare IndexedKeys: type (STRING), section (STRING), plants (STRINGLIST). Only indexed keys may appear in a metadataFilters expression; filtering an unindexed key raises ValidationException. Kept to three because indexed keys are additive-only, capped at 10, and cannot be removed once added. photo_count stays unindexed enrichment. - Give each strategy a MemoryRecordSchema.MetadataSchema. Definition + LlmExtractionInstruction are the instructions handed to the extraction model, and Validation.AllowedValues/MaxItems constrain output so filter values stay consistent. This is the supported way to guide extraction, rather than an AppendToPrompt override. - Remove SummaryMemoryStrategy. It produced nothing over weeks of real use (94 records in the live namespace, all semantic/user-preference; its old episodic namespace held zero), it restated in looser prose what semantic extraction already stores densely, and it accumulated one record per session competing for the capped retrieval budget. The absence is documented, including the constraint that a re-added summarization namespace must end in {sessionId}. Runtime (server.py): - retrieve() pushes indexed keys down as metadataFilters, which the service applies BEFORE the vector search, so filters narrow the candidate set rather than trimming top-K results. build_metadata_filters splits indexed from unindexed keys and uses CONTAINS for the plants STRINGLIST; unindexed keys are filtered in-process. - retrieve() queries namespacePath, not namespace. Verified against the service: namespace matches only the EXACT value given, so anything nested deeper is silently omitted (the API docs describing it as a prefix are misleading). - persist() forwards CreateEvent metadata, and a captioned album attaches type/section at event time so extraction propagates the same dimension onto derived records instead of re-inferring it. Metadata is therefore no longer added only through the batch API. CreateEvent metadata is stringValue-only, which is why list-valued plants still travels the direct-write path. - Add temporal scoping via build_created_after_filter / retrieve(created_after=...) on the reserved x-amz-agentcore-memory-createdAt field, which is filterable without consuming an indexed-key slot. Deliberately not applied to the chat or reminder paths: those need the oldest durable facts ("grows basil in containers", "zone 9b"), which a recency bound would hide. - build_section_summary keeps the directly written record terse. One album turn yields several records describing the same bed (this one plus whatever extraction derives), and embedding the agent's full reply made ours the longest while adding no fact the others lacked. - Document why memoryStrategyId is omitted on BatchCreateMemoryRecords: supplying it filters metadata to that strategy's schema and silently drops the rest, which would discard photo_count. - Drop the dead derive_episodic_namespace helper and EPISODIC_SEGMENT. Verified against the live service, not only in tests: section=north_bed -> 1, plants CONTAINS basil -> 1, section=missing_bed -> 0, unindexed photo_count -> ValidationException; temporal AFTER 2020 -> 1 and AFTER tomorrow -> 0; a deep- namespace record is returned via namespacePath but not via namespace. Change sets were checked for Replace=False on AgentCoreMemory before each apply, so stored memories were never at risk, and get_memory confirms the final strategy set. Note record indexing takes roughly 60-75 seconds, so an immediate post-write query returns nothing and can look like a failure. 161 agent-container + 39 webhook tests pass; template validates; bandit 0 HIGH. --- README.md | 53 ++- agent-container/server.py | 289 ++++++++++++--- agent-container/tests/test_album.py | 2 +- agent-container/tests/test_cron_nudge.py | 2 +- agent-container/tests/test_memory_metadata.py | 339 ++++++++++++++++-- agent-container/tests/test_scheduler.py | 2 +- agent-container/tests/test_server.py | 9 +- docs/configuration.md | 69 +++- docs/threat-model.md | 2 +- openclaw-telegram.yaml | 127 ++++++- 10 files changed, 803 insertions(+), 91 deletions(-) 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