Skip to content

Commit 0e6e6bf

Browse files
committed
chore: update DP APIs to support namespace re-design
1 parent 65fb85c commit 0e6e6bf

8 files changed

Lines changed: 167 additions & 36 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Namespace utilities for data plane API calls."""
2+
3+
from typing import Dict, Optional
4+
5+
6+
def build_namespace_params(namespace: Optional[str] = None, namespace_path: Optional[str] = None) -> Dict[str, str]:
7+
"""Build the namespace kwargs for a data plane API call.
8+
9+
Exactly one of ``namespace`` (exact match) or ``namespace_path``
10+
(hierarchical path prefix) must be provided. Wildcards (``*``) are not
11+
supported in either field.
12+
13+
Raises:
14+
ValueError: if both arguments are provided, neither is provided, or
15+
the provided value contains a wildcard.
16+
"""
17+
if namespace is not None and namespace_path is not None:
18+
raise ValueError("'namespace' and 'namespace_path' are mutually exclusive.")
19+
if namespace is None and namespace_path is None:
20+
raise ValueError("At least one of 'namespace' or 'namespace_path' must be provided.")
21+
22+
value = namespace if namespace is not None else namespace_path
23+
if "*" in value:
24+
raise ValueError("Wildcards (*) are not supported in namespaces.")
25+
26+
if namespace is not None:
27+
return {"namespace": namespace}
28+
return {"namespacePath": namespace_path}

src/bedrock_agentcore/memory/client.py

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from botocore.config import Config
2121
from botocore.exceptions import ClientError
2222

23+
from bedrock_agentcore._utils.namespace import build_namespace_params
2324
from bedrock_agentcore._utils.snake_case import accept_snake_case_kwargs
2425
from bedrock_agentcore._utils.user_agent import build_user_agent_suffix
2526

@@ -126,8 +127,11 @@ def __getattr__(self, name: str):
126127
# Access any boto3 method directly
127128
client = MemoryClient()
128129
129-
# These calls are forwarded to the appropriate boto3 client
130-
response = client.list_memory_records(memoryId="mem-123", namespace="test/")
130+
# These calls are forwarded to the appropriate boto3 client.
131+
# Use `namespace` for exact match, or `namespace_path` for
132+
# hierarchical path-prefix retrieval.
133+
response = client.list_memory_records(memoryId="mem-123", namespace="/actor/Jane/")
134+
response = client.list_memory_records(memoryId="mem-123", namespace_path="/org/MyOrg/")
131135
metadata = client.get_memory_metadata(memoryId="mem-123")
132136
"""
133137
if name in self._ALLOWED_GMDP_METHODS and hasattr(self.gmdp_client, name):
@@ -307,46 +311,48 @@ def create_memory_and_wait(
307311
raise TimeoutError("Memory %s did not become ACTIVE within %d seconds" % (memory_id, max_wait))
308312

309313
def retrieve_memories(
310-
self, memory_id: str, namespace: str, query: str, actor_id: Optional[str] = None, top_k: int = 3
314+
self,
315+
memory_id: str,
316+
namespace: Optional[str] = None,
317+
query: str = None,
318+
actor_id: Optional[str] = None,
319+
top_k: int = 3,
320+
namespace_path: Optional[str] = None,
311321
) -> List[Dict[str, Any]]:
312-
"""Retrieve relevant memories from a namespace.
322+
"""Retrieve relevant memories using exact match or hierarchical path prefix.
313323
314-
Note: Wildcards (*) are NOT supported in namespaces. You must provide the
315-
exact namespace path with all variables resolved.
324+
Exactly one of ``namespace`` or ``namespace_path`` must be provided.
316325
317326
Args:
318327
memory_id: Memory resource ID
319-
namespace: Exact namespace path (no wildcards)
320-
query: Search query
328+
namespace: Exact namespace to match (e.g., "/actor/Jane/")
329+
query: Search query (required)
321330
actor_id: Optional actor ID (deprecated, use namespace)
322331
top_k: Number of results to return
332+
namespace_path: Hierarchical path prefix (e.g., "/org/team/")
323333
324334
Returns:
325-
List of memory records
326-
327-
Example:
328-
# Correct - exact namespace
329-
memories = client.retrieve_memories(
330-
memory_id="mem-123",
331-
namespace="support/facts/session-456/",
332-
query="customer preferences"
333-
)
334-
335-
# Incorrect - wildcards not supported
336-
# memories = client.retrieve_memories(..., namespace="support/facts/*/", ...)
335+
List of memory records. Returns an empty list if the namespace
336+
arguments are invalid (both provided, neither provided, or contain
337+
wildcards) or if the service call fails.
337338
"""
338-
if "*" in namespace:
339-
logger.error("Wildcards are not supported in namespaces. Please provide exact namespace.")
339+
if query is None:
340+
raise TypeError("retrieve_memories() missing required argument: 'query'")
341+
342+
try:
343+
ns_params = build_namespace_params(namespace, namespace_path)
344+
except ValueError as e:
345+
logger.error(str(e))
340346
return []
341347

348+
ns_value = namespace or namespace_path
349+
342350
try:
343-
# Let service handle all namespace validation
344351
response = self.gmdp_client.retrieve_memory_records(
345-
memoryId=memory_id, namespace=namespace, searchCriteria={"searchQuery": query, "topK": top_k}
352+
memoryId=memory_id, searchCriteria={"searchQuery": query, "topK": top_k}, **ns_params
346353
)
347-
348354
memories = response.get("memoryRecordSummaries", [])
349-
logger.info("Retrieved %d memories from namespace: %s", len(memories), namespace)
355+
logger.info("Retrieved %d memories from namespace: %s", len(memories), ns_value)
350356
return memories
351357

352358
except ClientError as e:
@@ -357,7 +363,7 @@ def retrieve_memories(
357363
logger.warning(
358364
"Memory or namespace not found. Ensure memory %s exists and namespace '%s' is configured",
359365
memory_id,
360-
namespace,
366+
ns_value,
361367
)
362368
elif error_code == "ValidationException":
363369
logger.warning("Invalid search parameters: %s", error_msg)
@@ -662,6 +668,7 @@ def process_turn_with_llm(
662668
retrieval_query: Optional[str] = None,
663669
top_k: int = 3,
664670
event_timestamp: Optional[datetime] = None,
671+
retrieval_namespace_path: Optional[str] = None,
665672
) -> Tuple[List[Dict[str, Any]], str, Dict[str, Any]]:
666673
r"""Complete conversation turn with LLM callback integration.
667674
@@ -676,10 +683,11 @@ def process_turn_with_llm(
676683
llm_callback: Function that takes (user_input, memories) and returns agent_response
677684
The callback receives the user input and retrieved memories,
678685
and should return the agent's response string
679-
retrieval_namespace: Namespace to search for memories (optional)
686+
retrieval_namespace: Namespace for exact match retrieval (optional)
680687
retrieval_query: Custom search query (defaults to user_input)
681688
top_k: Number of memories to retrieve
682689
event_timestamp: Optional timestamp for the event
690+
retrieval_namespace_path: Namespace path for hierarchical prefix retrieval (optional)
683691
684692
Returns:
685693
Tuple of (retrieved_memories, agent_response, created_event)
@@ -709,10 +717,14 @@ def my_llm(user_input: str, memories: List[Dict]) -> str:
709717
"""
710718
# Step 1: Retrieve relevant memories
711719
retrieved_memories = []
712-
if retrieval_namespace:
720+
if retrieval_namespace or retrieval_namespace_path:
713721
search_query = retrieval_query or user_input
714722
retrieved_memories = self.retrieve_memories(
715-
memory_id=memory_id, namespace=retrieval_namespace, query=search_query, top_k=top_k
723+
memory_id=memory_id,
724+
namespace=retrieval_namespace,
725+
namespace_path=retrieval_namespace_path,
726+
query=search_query,
727+
top_k=top_k,
716728
)
717729
logger.info("Retrieved %d memories for LLM context", len(retrieved_memories))
718730

src/bedrock_agentcore/memory/integrations/strands/session_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,7 @@ def retrieve_for_namespace(namespace: str, retrieval_config: RetrievalConfig):
848848

849849
memories = self.memory_client.retrieve_memories(
850850
memory_id=self.config.memory_id,
851-
namespace=resolved_namespace,
851+
namespace_path=resolved_namespace,
852852
query=user_query,
853853
top_k=retrieval_config.top_k,
854854
)

src/bedrock_agentcore/memory/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -913,7 +913,7 @@ def search_long_term_memories(
913913
params = {
914914
"memoryId": self._memory_id,
915915
"searchCriteria": search_criteria,
916-
"namespace": namespace,
916+
"namespacePath": namespace,
917917
"maxResults": max_results,
918918
}
919919

@@ -940,7 +940,7 @@ def list_long_term_memory_records(
940940

941941
params = {
942942
"memoryId": self._memory_id,
943-
"namespace": namespace_prefix,
943+
"namespacePath": namespace_prefix,
944944
}
945945

946946
if strategy_id:

tests/bedrock_agentcore/memory/test_client.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Unit tests for Memory Client - no external connections."""
22

3+
import logging
34
import time
45
import uuid
56
import warnings
67
from datetime import datetime
78
from unittest.mock import MagicMock, patch
89

10+
import pytest
911
from botocore.exceptions import ClientError
1012

1113
from bedrock_agentcore.memory import MemoryClient
@@ -1627,6 +1629,68 @@ def test_retrieve_memories_wildcard_namespace():
16271629
assert not mock_gmdp.retrieve_memory_records.called
16281630

16291631

1632+
def test_retrieve_memories_with_namespace_path():
1633+
"""Test retrieve_memories uses namespacePath for hierarchical retrieval."""
1634+
with patch("boto3.Session"):
1635+
client = MemoryClient()
1636+
1637+
mock_gmdp = MagicMock()
1638+
mock_gmdp.retrieve_memory_records.return_value = {"memoryRecordSummaries": [{"memoryRecordId": "rec-1"}]}
1639+
client.gmdp_client = mock_gmdp
1640+
1641+
result = client.retrieve_memories(memory_id="mem-123", namespace_path="/org/team/", query="test", top_k=3)
1642+
1643+
assert len(result) == 1
1644+
call_kwargs = mock_gmdp.retrieve_memory_records.call_args[1]
1645+
assert "namespacePath" in call_kwargs
1646+
assert call_kwargs["namespacePath"] == "/org/team/"
1647+
assert "namespace" not in call_kwargs
1648+
1649+
1650+
def test_retrieve_memories_mutual_exclusivity(caplog):
1651+
"""Test retrieve_memories soft-fails when both namespace and namespace_path are passed."""
1652+
with patch("boto3.Session"):
1653+
client = MemoryClient()
1654+
1655+
mock_gmdp = MagicMock()
1656+
client.gmdp_client = mock_gmdp
1657+
1658+
with caplog.at_level(logging.ERROR):
1659+
result = client.retrieve_memories(memory_id="mem-123", namespace="/a/", namespace_path="/b/", query="test")
1660+
1661+
# Should log the error and return [] without calling the service
1662+
assert result == []
1663+
assert not mock_gmdp.retrieve_memory_records.called
1664+
assert any(
1665+
"mutually exclusive" in record.message and record.levelno == logging.ERROR for record in caplog.records
1666+
)
1667+
1668+
1669+
def test_retrieve_memories_missing_namespace_and_path(caplog):
1670+
"""Test retrieve_memories soft-fails when neither namespace nor namespace_path is passed."""
1671+
with patch("boto3.Session"):
1672+
client = MemoryClient()
1673+
1674+
mock_gmdp = MagicMock()
1675+
client.gmdp_client = mock_gmdp
1676+
1677+
with caplog.at_level(logging.ERROR):
1678+
result = client.retrieve_memories(memory_id="mem-123", query="test")
1679+
1680+
assert result == []
1681+
assert not mock_gmdp.retrieve_memory_records.called
1682+
assert any("At least one" in record.message and record.levelno == logging.ERROR for record in caplog.records)
1683+
1684+
1685+
def test_retrieve_memories_missing_query_raises():
1686+
"""Test retrieve_memories raises TypeError when query is omitted."""
1687+
with patch("boto3.Session"):
1688+
client = MemoryClient()
1689+
1690+
with pytest.raises(TypeError, match="query"):
1691+
client.retrieve_memories(memory_id="mem-123", namespace="/actor/Jane/")
1692+
1693+
16301694
def test_add_semantic_strategy_and_wait():
16311695
"""Test add_semantic_strategy_and_wait functionality."""
16321696
with patch("boto3.Session"):

tests/bedrock_agentcore/memory/test_session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1197,7 +1197,7 @@ def test_search_long_term_memories_success(self):
11971197
assert call_args["memoryId"] == "testMemory-1234567890"
11981198
assert call_args["searchCriteria"]["searchQuery"] == "test query"
11991199
assert call_args["searchCriteria"]["topK"] == 5
1200-
assert call_args["namespace"] == "test/namespace/"
1200+
assert call_args["namespacePath"] == "test/namespace/"
12011201

12021202
def test_search_long_term_memories_with_strategy(self):
12031203
"""Test search_long_term_memories with strategy_id."""

tests/unit/test_utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66
from botocore.exceptions import ClientError
77

8+
from bedrock_agentcore._utils.namespace import build_namespace_params
89
from bedrock_agentcore._utils.polling import wait_until, wait_until_deleted
910

1011

@@ -112,3 +113,29 @@ def test_timeout(self, _mock_time, _mock_sleep):
112113
poll_fn = Mock(return_value={"status": "DELETING"})
113114
with pytest.raises(TimeoutError):
114115
wait_until_deleted(poll_fn)
116+
117+
118+
class TestBuildNamespaceParams:
119+
"""Tests for build_namespace_params utility."""
120+
121+
def test_namespace_only(self):
122+
assert build_namespace_params(namespace="/actor/Jane/") == {"namespace": "/actor/Jane/"}
123+
124+
def test_namespace_path_only(self):
125+
assert build_namespace_params(namespace_path="/org/team/") == {"namespacePath": "/org/team/"}
126+
127+
def test_both_raises(self):
128+
with pytest.raises(ValueError, match="mutually exclusive"):
129+
build_namespace_params(namespace="/a/", namespace_path="/b/")
130+
131+
def test_neither_raises(self):
132+
with pytest.raises(ValueError, match="At least one"):
133+
build_namespace_params()
134+
135+
def test_wildcard_in_namespace_raises(self):
136+
with pytest.raises(ValueError, match="[Ww]ildcard"):
137+
build_namespace_params(namespace="/actor/*/")
138+
139+
def test_wildcard_in_namespace_path_raises(self):
140+
with pytest.raises(ValueError, match="[Ww]ildcard"):
141+
build_namespace_params(namespace_path="/org/*/team/")

tests_integ/memory/test_memory_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,11 +371,11 @@ def test_retrieve_summary_memories(self):
371371
assert len(results) > 0, "Expected summary memories in /summaries/ namespace"
372372

373373
@pytest.mark.order(14)
374-
# retrieve_memories with a prefix namespace matches broader results
374+
# retrieve_memories with a path prefix matches all descendants under /facts/
375375
def test_retrieve_memories_prefix_namespace(self):
376376
results = self.client.retrieve_memories(
377377
memory_id=self.prepopulated_memory_id,
378-
namespace="/facts/",
378+
namespace_path="/facts/",
379379
query="developer preferences",
380380
)
381381
assert len(results) > 0, "Expected prefix namespace to match"

0 commit comments

Comments
 (0)