Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions sdk/python/agentfield/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1638,9 +1638,8 @@ async def get_conversation_summary() -> dict:
Read a value; with `scope=None` this is the hierarchical lookup above
- `await app.memory.set(key, data, scope=None, scope_id=None)`: Store a value
- `await app.memory.delete(key, scope=None, scope_id=None)`: Remove a value
- `await app.memory.exists(key)`: Present, but it answers True for any key
as long as the backend is reachable; compare
`await app.memory.get(key, sentinel)` against a sentinel instead
- `await app.memory.exists(key)`: True when the key is stored in any
reachable scope, False when it is absent or the lookup itself failed
- `await app.memory.set_vector(key, embedding, metadata=None)`: Store an embedding
- `await app.memory.delete_vector(key)`: Remove an embedding
- `await app.memory.similarity_search(query_embedding, top_k=10, filters=None,
Expand Down
39 changes: 21 additions & 18 deletions sdk/python/agentfield/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,24 +385,26 @@ async def exists(
"""
Check if a memory key exists.

Known limitation: ``get`` returns ``default`` rather than raising when the
key is absent, so this answers ``True`` for any key as long as the memory
backend is reachable. Only a transport or backend failure yields ``False``.
For a real existence test, compare ``get(key, sentinel)`` against a
sentinel of your own.
``get`` answers the control plane's 404 with its ``default`` rather than
raising, so presence is decided against a private sentinel: a stored value
— including a falsy one such as ``None``, ``0`` or ``""`` — means ``True``,
while a missing key means ``False``. A transport or backend failure also
yields ``False``.

Args:
key: The memory key
scope: Optional explicit scope override

Returns:
True unless the lookup itself failed.
True when the key is stored, False when it is absent or the lookup
itself failed.
"""
missing = object()
try:
await self.get(key, scope=scope, scope_id=scope_id)
return True
value = await self.get(key, default=missing, scope=scope, scope_id=scope_id)
except Exception:
return False
return value is not missing

async def delete(
self, key: str, scope: Optional[str] = None, scope_id: Optional[str] = None
Expand Down Expand Up @@ -581,8 +583,8 @@ async def get(self, key: str, default: Any = None) -> Any:
async def exists(self, key: str) -> bool:
"""Check if a key exists in this specific scope.

Carries the same limitation as ``MemoryClient.exists``: it answers ``True``
whenever the backend is reachable.
Delegates to ``MemoryClient.exists``, so a key this scope has never stored
reads as ``False``; see there for the falsy-value and failure semantics.
"""
return await self.memory_client.exists(
key, scope=self.scope, scope_id=self.scope_id
Expand Down Expand Up @@ -679,8 +681,8 @@ async def get(self, key: str, default: Any = None) -> Any:
async def exists(self, key: str) -> bool:
"""Check if a key exists in global scope.

Carries the same limitation as ``MemoryClient.exists``: it answers ``True``
whenever the backend is reachable.
Delegates to ``MemoryClient.exists``, so a key never stored in global scope
reads as ``False``; see there for the falsy-value and failure semantics.
"""
return await self.memory_client.exists(key, scope="global")

Expand Down Expand Up @@ -933,17 +935,18 @@ async def exists(self, key: str) -> bool:
"""
Check if a memory key exists in any scope.

Known limitation: this answers ``True`` for any key as long as the memory
backend is reachable - a missing key is not distinguished from a stored one
(see ``MemoryClient.exists``). Compare
``await app.memory.get(key, sentinel)`` against a sentinel of your own
instead.
Delegates to ``MemoryClient.exists``, which walks the same
``workflow -> session -> actor -> global`` precedence as ``get``: a key
stored in any reachable scope reads as ``True``, a key that was never
stored reads as ``False``, and a transport or backend failure also reads
as ``False``.

Args:
key: The memory key

Returns:
True unless the lookup itself failed.
True when the key is stored, False when it is absent or the lookup
itself failed.
"""
return await self.memory_client.exists(key)

Expand Down
75 changes: 75 additions & 0 deletions sdk/python/tests/test_memory_client_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,81 @@ async def request(self, method, url, params=None, headers=None, timeout=None):
assert result == ["a", "b"]


@pytest.mark.unit
@pytest.mark.asyncio
async def test_exists_is_false_when_the_control_plane_returns_404(
memory_client, monkeypatch
):
# The control plane answers a missing key with 404 (handlers/memory.go) and
# get() turns that into its default instead of raising, so "did not raise"
# cannot mean "is stored".
class NotFoundResponse:
status_code = 404

def raise_for_status(self):
return None

def json(self):
return {}

async def fake_request(method, url, json=None, headers=None, timeout=None): # type: ignore[override]
assert url.endswith("/memory/get")
return NotFoundResponse()

monkeypatch.setattr(memory_client, "_async_request", fake_request)

assert await memory_client.exists("never-stored") is False


@pytest.mark.unit
@pytest.mark.asyncio
async def test_exists_is_true_for_a_stored_falsy_value(memory_client, monkeypatch):
# A stored null/0/"" is present; only the 404 means missing.
class StoredNullResponse:
status_code = 200

def raise_for_status(self):
return None

def json(self):
return {"data": "null"}

async def fake_request(method, url, json=None, headers=None, timeout=None): # type: ignore[override]
return StoredNullResponse()

monkeypatch.setattr(memory_client, "_async_request", fake_request)

assert await memory_client.exists("stored-null") is True


@pytest.mark.unit
@pytest.mark.asyncio
async def test_scoped_exists_is_false_for_a_missing_key(memory_client, monkeypatch):
# The scoped/global/interface surfaces delegate here, so a missing key must
# read as absent through them too.
class NotFoundResponse:
status_code = 404

def raise_for_status(self):
return None

def json(self):
return {}

captured: dict[str, Any] = {}

async def fake_request(method, url, json=None, headers=None, timeout=None): # type: ignore[override]
captured["json"] = json
return NotFoundResponse()

monkeypatch.setattr(memory_client, "_async_request", fake_request)

scoped = ScopedMemoryClient(memory_client, "session", "sess-1", event_client=None)

assert await scoped.exists("never-stored") is False
assert captured["json"]["scope"] == "session" # type: ignore[index]


@pytest.mark.unit
@pytest.mark.asyncio
async def test_exists_handles_errors(memory_client, monkeypatch):
Expand Down
Loading