From f2ce9cb1afdb0fb54ffd2f465caad28ef0be1e5c Mon Sep 17 00:00:00 2001 From: Santosh Date: Thu, 2 Jul 2026 18:42:27 -0400 Subject: [PATCH 1/3] feat(sdk-python): accept scope kwargs on memory set/get/delete and correct README search API Add optional scope/scope_id kwargs to app.memory.set/get/delete so the developer-facing MemoryInterface matches what humans and LLMs naturally guess (app.memory.set(key, data, scope="global")) and mirrors the TypeScript SDK, which already accepts scope positionally. scope=None keeps today's hierarchical behavior unchanged; explicit scopes route to the existing accessor clients. Invalid scopes raise a ValueError listing valid scopes; non-global scopes without a scope_id raise a clear ValueError. No general memory search endpoint exists on the control plane (only /api/v1/memory/vector/search, already exposed as similarity_search), so the README's advertised app.memory.search(...) is corrected to similarity_search and the scope names are corrected from 'agent/run' to 'actor/workflow'. --- README.md | 6 +- sdk/python/agentfield/memory.py | 176 ++++++++++++++--- sdk/python/tests/test_memory_scope_kwargs.py | 190 +++++++++++++++++++ 3 files changed, 346 insertions(+), 26 deletions(-) create mode 100644 sdk/python/tests/test_memory_scope_kwargs.py diff --git a/README.md b/README.md index 111e116ba..b4d2c8349 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ Two examples already run at this load. The [deep-research engine](https://agentf - **[Harness](https://agentfield.ai/docs/build/intelligence/harness?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-harness)** - `app.harness("Fix the bug")` dispatches multi-turn tasks to Claude Code, Codex, Gemini CLI, or OpenCode - **[Cross-Agent Calls](https://agentfield.ai/docs/build/coordination/cross-agent-calls?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-cross-agent-calls)** - `app.call("other-agent.func")` routes through the control plane with full tracing - **[Discovery](https://agentfield.ai/docs/reference/sdks/python?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-discovery)** - `app.discover(tags=["ml*"])` finds agents and capabilities across the mesh. `tools="discover"` lets LLMs auto-invoke them. -- **[Memory](https://agentfield.ai/docs/build/coordination/shared-memory?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-memory)** - `app.memory.set()` / `.get()` / `.search()` - KV + vector search, four scopes, no Redis needed +- **[Memory](https://agentfield.ai/docs/build/coordination/shared-memory?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-memory)** - `app.memory.set()` / `.get()` / `.similarity_search()` - KV + vector search, four scopes, no Redis needed **Scale** - Production infrastructure for non-deterministic AI. @@ -314,8 +314,8 @@ Two examples already run at this load. The [deep-research engine](https://agentf | Feature | How | |---|---| | Key-value storage | `app.memory.set(key, value)` / `.get(key)` | -| Vector search (semantic) | `app.memory.search(embedding, top_k=5)` | -| Four scopes | Global, agent, session, run | +| Vector search (semantic) | `app.memory.similarity_search(embedding, top_k=5)` | +| Four scopes | Global, actor, session, workflow | | Reactive memory events | `@app.memory.on_change("order_*")` | | Metadata filtering | Filter stored values by metadata | | Zero dependencies | Built into control plane - no Redis | diff --git a/sdk/python/agentfield/memory.py b/sdk/python/agentfield/memory.py index 46e80683e..f76f4f265 100644 --- a/sdk/python/agentfield/memory.py +++ b/sdk/python/agentfield/memory.py @@ -113,6 +113,7 @@ if sys.version_info >= (3, 9): from asyncio import to_thread as _to_thread else: + async def _to_thread(func, *args, **kwargs): """Compatibility shim for asyncio.to_thread on Python 3.8.""" loop = asyncio.get_event_loop() @@ -184,7 +185,11 @@ async def _async_request(self, method: str, url: str, **kwargs): return await _to_thread(requests.request, method, url, **kwargs) async def set( - self, key: str, data: Any, scope: Optional[str] = None, scope_id: Optional[str] = None + self, + key: str, + data: Any, + scope: Optional[str] = None, + scope_id: Optional[str] = None, ) -> None: """ Set a memory value with automatic scoping. @@ -423,13 +428,9 @@ async def delete_vector( except MemoryAccessError: raise except Exception as e: - raise MemoryAccessError( - f"Failed to delete vector key '{key}': {e}" - ) from e + raise MemoryAccessError(f"Failed to delete vector key '{key}': {e}") from e - async def list_keys( - self, scope: str, scope_id: Optional[str] = None - ) -> List[str]: + async def list_keys(self, scope: str, scope_id: Optional[str] = None) -> List[str]: """ List all keys in a specific scope. @@ -550,9 +551,7 @@ async def exists(self, key: str) -> bool: async def delete(self, key: str) -> None: """Delete a value from this specific scope.""" - await self.memory_client.delete( - key, scope=self.scope, scope_id=self.scope_id - ) + await self.memory_client.delete(key, scope=self.scope, scope_id=self.scope_id) async def list_keys(self) -> List[str]: """List all keys in this specific scope.""" @@ -720,34 +719,119 @@ async def wrapper(event): return decorator +# Scopes that a developer may target explicitly via the convenience kwargs on +# MemoryInterface.set/get/delete. These are the canonical scope names the +# control plane understands (see control-plane memory handlers). "global" is a +# singleton scope and needs no scope_id; the other three require one. +_VALID_SCOPES = ("global", "session", "actor", "workflow") + + class MemoryInterface: """ Developer-facing memory interface that provides the intuitive app.memory API. This class provides the main interface that developers interact with, offering automatic scoping, hierarchical lookup, and explicit scope access. + + Scoped access is primarily expressed through the accessor API, which reads + the clearest at call sites:: + + await app.memory.global_scope.set("config", {...}) + await app.memory.session(session_id).set("context", {...}) + await app.memory.actor(actor_id).get("preferences") + await app.memory.workflow(workflow_id).set("step1", {...}) + + As a convenience (and to mirror the TypeScript SDK), ``set``/``get``/``delete`` + also accept optional ``scope`` and ``scope_id`` keyword arguments. Passing + ``scope=None`` (the default) keeps the automatic hierarchical behavior; passing + an explicit scope routes to the equivalent accessor above. """ def __init__(self, memory_client: MemoryClient, event_client: MemoryEventClient): self.memory_client = memory_client self.events = event_client - async def set(self, key: str, data: Any) -> None: + def _resolve_scope_target( + self, scope: str, scope_id: Optional[str] + ) -> Union["GlobalMemoryClient", "ScopedMemoryClient"]: + """ + Map an explicit ``scope``/``scope_id`` pair to the equivalent scoped client. + + This is the shared dispatch used by the ``scope`` kwargs on + ``set``/``get``/``delete``. It reuses the existing accessor clients so the + convenience layer stays a thin wrapper over one code path. + + Args: + scope: One of ``"global"``, ``"session"``, ``"actor"``, ``"workflow"``. + scope_id: Identifier for the scope. Required for ``session``/``actor``/ + ``workflow``; ignored for ``global``. + + Returns: + A ``GlobalMemoryClient`` or ``ScopedMemoryClient`` bound to the scope. + + Raises: + ValueError: If ``scope`` is not a recognized scope name, or if a + non-global scope is given without a ``scope_id``. + """ + if scope not in _VALID_SCOPES: + valid = ", ".join(repr(s) for s in _VALID_SCOPES) + raise ValueError( + f"Invalid memory scope {scope!r}. Valid scopes are: {valid}." + ) + + if scope == "global": + return self.global_scope + + if scope_id is None: + raise ValueError( + f"scope_id is required when scope={scope!r}. " + f"Provide scope_id, or use the accessor API, e.g. " + f"app.memory.{scope}().set(...)." + ) + + return ScopedMemoryClient(self.memory_client, scope, scope_id, self.events) + + async def set( + self, + key: str, + data: Any, + scope: Optional[str] = None, + scope_id: Optional[str] = None, + ) -> None: """ Set a memory value with automatic scoping. - The value will be stored in the most specific available scope - based on the current execution context. + By default (``scope=None``) the value is stored in the most specific + available scope based on the current execution context. + + The accessor API is the primary way to target a scope explicitly:: + + await app.memory.session(session_id).set("context", {...}) + + As a convenience, an explicit scope may also be passed here:: + + await app.memory.set("context", {...}, scope="session", scope_id=session_id) + await app.memory.set("config", {...}, scope="global") Args: key: The memory key data: The data to store + scope: Optional explicit scope. One of ``"global"``, ``"session"``, + ``"actor"``, ``"workflow"``. ``None`` keeps automatic scoping. + scope_id: Identifier for the scope. Required for ``session``/``actor``/ + ``workflow`` when ``scope`` is set; ignored for ``global``. Raises: + ValueError: If ``scope`` is invalid, or if a non-global scope is given + without a ``scope_id``. TypeError: If data is not JSON serializable. MemoryAccessError: If the memory backend request fails. """ - await self.memory_client.set(key, data) + if scope is None: + await self.memory_client.set(key, data) + return + + await self._resolve_scope_target(scope, scope_id).set(key, data) async def set_vector( self, @@ -763,24 +847,48 @@ async def set_vector( """ await self.memory_client.set_vector(key, embedding, metadata=metadata) - async def get(self, key: str, default: Any = None) -> Any: + async def get( + self, + key: str, + default: Any = None, + scope: Optional[str] = None, + scope_id: Optional[str] = None, + ) -> Any: """ - Get a memory value with hierarchical lookup. + Get a memory value. - This will search through scopes in order: workflow -> session -> actor -> global - and return the first match found. + By default (``scope=None``) this performs a hierarchical lookup, searching + scopes in order ``workflow -> session -> actor -> global`` and returning the + first match found. + + When an explicit ``scope`` is passed, only that scope is read (matching the + accessor API):: + + await app.memory.get("context", scope="session", scope_id=session_id) + await app.memory.get("config", scope="global") Args: key: The memory key - default: Default value if key not found in any scope + default: Default value if key not found + scope: Optional explicit scope. One of ``"global"``, ``"session"``, + ``"actor"``, ``"workflow"``. ``None`` performs hierarchical lookup. + scope_id: Identifier for the scope. Required for ``session``/``actor``/ + ``workflow`` when ``scope`` is set; ignored for ``global``. Returns: The stored value or default if not found Raises: + ValueError: If ``scope`` is invalid, or if a non-global scope is given + without a ``scope_id``. MemoryAccessError: If the memory backend request fails. """ - return await self.memory_client.get(key, default=default) + if scope is None: + return await self.memory_client.get(key, default=default) + + return await self._resolve_scope_target(scope, scope_id).get( + key, default=default + ) async def exists(self, key: str) -> bool: """ @@ -794,17 +902,39 @@ async def exists(self, key: str) -> bool: """ return await self.memory_client.exists(key) - async def delete(self, key: str) -> None: + async def delete( + self, + key: str, + scope: Optional[str] = None, + scope_id: Optional[str] = None, + ) -> None: """ - Delete a memory value from the current scope. + Delete a memory value. + + By default (``scope=None``) the value is deleted from the current scope. + When an explicit ``scope`` is passed, the value is deleted from that scope + (matching the accessor API):: + + await app.memory.delete("context", scope="session", scope_id=session_id) + await app.memory.delete("config", scope="global") Args: key: The memory key + scope: Optional explicit scope. One of ``"global"``, ``"session"``, + ``"actor"``, ``"workflow"``. ``None`` uses the current scope. + scope_id: Identifier for the scope. Required for ``session``/``actor``/ + ``workflow`` when ``scope`` is set; ignored for ``global``. Raises: + ValueError: If ``scope`` is invalid, or if a non-global scope is given + without a ``scope_id``. MemoryAccessError: If the memory backend request fails. """ - await self.memory_client.delete(key) + if scope is None: + await self.memory_client.delete(key) + return + + await self._resolve_scope_target(scope, scope_id).delete(key) async def delete_vector(self, key: str) -> None: """ diff --git a/sdk/python/tests/test_memory_scope_kwargs.py b/sdk/python/tests/test_memory_scope_kwargs.py new file mode 100644 index 000000000..57106c562 --- /dev/null +++ b/sdk/python/tests/test_memory_scope_kwargs.py @@ -0,0 +1,190 @@ +"""Unit tests for the convenience ``scope``/``scope_id`` kwargs on MemoryInterface. + +These cover the developer-facing ``app.memory.set/get/delete`` convenience layer +added to mirror the TypeScript SDK. The kwargs delegate to the same scoped clients +the accessor API (``global_scope``/``session()``/``actor()``/``workflow()``) uses, +so the tests assert the delegation reaches the underlying MemoryClient with the +right scope/scope_id. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from agentfield.memory import MemoryClient, MemoryInterface + + +@pytest.fixture +def memory_client(dummy_headers): + context = SimpleNamespace(to_headers=lambda: dict(dummy_headers)) + agentfield_client = SimpleNamespace(api_base="http://agentfield.local/api/v1") + return MemoryClient(agentfield_client, context) + + +@pytest.fixture +def interface(memory_client): + """MemoryInterface with a stubbed low-level client and no event client.""" + memory_client.set = AsyncMock() # type: ignore[assignment] + memory_client.get = AsyncMock(return_value="stored") # type: ignore[assignment] + memory_client.delete = AsyncMock() # type: ignore[assignment] + return MemoryInterface(memory_client, None) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- # +# Default behavior unchanged (scope=None) +# --------------------------------------------------------------------------- # + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_set_default_scope_unchanged(interface): + await interface.set("key", {"v": 1}) + interface.memory_client.set.assert_awaited_once_with("key", {"v": 1}) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_get_default_scope_unchanged(interface): + result = await interface.get("key", default="fallback") + interface.memory_client.get.assert_awaited_once_with("key", default="fallback") + assert result == "stored" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_delete_default_scope_unchanged(interface): + await interface.delete("key") + interface.memory_client.delete.assert_awaited_once_with("key") + + +# --------------------------------------------------------------------------- # +# Explicit scope kwarg delegation — set +# --------------------------------------------------------------------------- # + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_set_scope_global(interface): + await interface.set("config", {"v": 1}, scope="global") + interface.memory_client.set.assert_awaited_once_with( + "config", {"v": 1}, scope="global" + ) + + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize("scope", ["session", "actor", "workflow"]) +async def test_set_scope_with_id(interface, scope): + await interface.set("ctx", {"v": 1}, scope=scope, scope_id="id-123") + interface.memory_client.set.assert_awaited_once_with( + "ctx", {"v": 1}, scope=scope, scope_id="id-123" + ) + + +# --------------------------------------------------------------------------- # +# Explicit scope kwarg delegation — get +# --------------------------------------------------------------------------- # + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_get_scope_global(interface): + result = await interface.get("config", scope="global") + interface.memory_client.get.assert_awaited_once_with( + "config", default=None, scope="global" + ) + assert result == "stored" + + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize("scope", ["session", "actor", "workflow"]) +async def test_get_scope_with_id(interface, scope): + await interface.get("ctx", default="d", scope=scope, scope_id="id-123") + interface.memory_client.get.assert_awaited_once_with( + "ctx", default="d", scope=scope, scope_id="id-123" + ) + + +# --------------------------------------------------------------------------- # +# Explicit scope kwarg delegation — delete +# --------------------------------------------------------------------------- # + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_delete_scope_global(interface): + await interface.delete("config", scope="global") + interface.memory_client.delete.assert_awaited_once_with("config", scope="global") + + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize("scope", ["session", "actor", "workflow"]) +async def test_delete_scope_with_id(interface, scope): + await interface.delete("ctx", scope=scope, scope_id="id-123") + interface.memory_client.delete.assert_awaited_once_with( + "ctx", scope=scope, scope_id="id-123" + ) + + +# --------------------------------------------------------------------------- # +# Validation +# --------------------------------------------------------------------------- # + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_invalid_scope_raises_value_error(interface): + with pytest.raises(ValueError) as exc_info: + await interface.set("k", 1, scope="agent") + + message = str(exc_info.value) + assert "agent" in message + # Error lists the valid scopes so the caller can self-correct. + for valid in ("global", "session", "actor", "workflow"): + assert valid in message + interface.memory_client.set.assert_not_awaited() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_invalid_scope_raises_on_get(interface): + with pytest.raises(ValueError): + await interface.get("k", scope="run") + interface.memory_client.get.assert_not_awaited() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_invalid_scope_raises_on_delete(interface): + with pytest.raises(ValueError): + await interface.delete("k", scope="bogus") + interface.memory_client.delete.assert_not_awaited() + + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize("scope", ["session", "actor", "workflow"]) +async def test_non_global_scope_without_id_raises(interface, scope): + with pytest.raises(ValueError) as exc_info: + await interface.set("k", 1, scope=scope) + + message = str(exc_info.value) + assert "scope_id" in message + # Points the caller at the accessor equivalent. + assert f"app.memory.{scope}(" in message + interface.memory_client.set.assert_not_awaited() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_global_scope_ignores_scope_id(interface): + # scope_id is accepted but irrelevant for global; it must not be forwarded. + await interface.set("config", {"v": 1}, scope="global", scope_id="ignored") + interface.memory_client.set.assert_awaited_once_with( + "config", {"v": 1}, scope="global" + ) From f21ce88109636ef0b4ae9cc8b4cf3a85bc58685f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 2 Jul 2026 18:58:23 -0400 Subject: [PATCH 2/3] fix(memory): allow context-derived explicit scopes --- README.md | 2 +- sdk/python/agentfield/memory.py | 42 +++++++++----------- sdk/python/tests/test_memory_scope_kwargs.py | 32 +++++++++++---- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index b4d2c8349..1bfbde047 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ Most agent tools help you **write** agent logic. AgentField is what **runs** it | Prebuilt chains, retrievers, integrations | ● | — | ◐ | [◐](https://agentfield.ai/docs/integrations?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-integrations) | | Production REST APIs out of the box | — | ◐ | ● | ● | | Async + retries + webhooks | — | ● | ◐ | **●** | -| Memory scopes (global · agent · session · run) | ◐ | — | — | ● | +| Memory scopes (global · actor · session · workflow) | ◐ | — | — | ● | | Service discovery + cross-agent calls | — | — | — | **●** | | Distributed agents (register from anywhere, one mesh) | — | ◐ | — | **●** | | Coding agents as functions (Claude Code · Codex · CLI) | — | — | — | **●** | diff --git a/sdk/python/agentfield/memory.py b/sdk/python/agentfield/memory.py index f76f4f265..c03fabee0 100644 --- a/sdk/python/agentfield/memory.py +++ b/sdk/python/agentfield/memory.py @@ -722,7 +722,8 @@ async def wrapper(event): # Scopes that a developer may target explicitly via the convenience kwargs on # MemoryInterface.set/get/delete. These are the canonical scope names the # control plane understands (see control-plane memory handlers). "global" is a -# singleton scope and needs no scope_id; the other three require one. +# singleton scope and needs no scope_id; the other three may use scope_id or the +# current execution context headers already carried by MemoryClient. _VALID_SCOPES = ("global", "session", "actor", "workflow") @@ -763,15 +764,15 @@ def _resolve_scope_target( Args: scope: One of ``"global"``, ``"session"``, ``"actor"``, ``"workflow"``. - scope_id: Identifier for the scope. Required for ``session``/``actor``/ - ``workflow``; ignored for ``global``. + scope_id: Optional identifier for the scope. When omitted for + ``session``/``actor``/``workflow``, the current execution context + headers are used; ignored for ``global``. Returns: A ``GlobalMemoryClient`` or ``ScopedMemoryClient`` bound to the scope. Raises: - ValueError: If ``scope`` is not a recognized scope name, or if a - non-global scope is given without a ``scope_id``. + ValueError: If ``scope`` is not a recognized scope name. """ if scope not in _VALID_SCOPES: valid = ", ".join(repr(s) for s in _VALID_SCOPES) @@ -782,13 +783,6 @@ def _resolve_scope_target( if scope == "global": return self.global_scope - if scope_id is None: - raise ValueError( - f"scope_id is required when scope={scope!r}. " - f"Provide scope_id, or use the accessor API, e.g. " - f"app.memory.{scope}().set(...)." - ) - return ScopedMemoryClient(self.memory_client, scope, scope_id, self.events) async def set( @@ -818,12 +812,12 @@ async def set( data: The data to store scope: Optional explicit scope. One of ``"global"``, ``"session"``, ``"actor"``, ``"workflow"``. ``None`` keeps automatic scoping. - scope_id: Identifier for the scope. Required for ``session``/``actor``/ - ``workflow`` when ``scope`` is set; ignored for ``global``. + scope_id: Optional identifier for the scope. When omitted for + ``session``/``actor``/``workflow``, the current execution context + headers are used; ignored for ``global``. Raises: - ValueError: If ``scope`` is invalid, or if a non-global scope is given - without a ``scope_id``. + ValueError: If ``scope`` is invalid. TypeError: If data is not JSON serializable. MemoryAccessError: If the memory backend request fails. """ @@ -872,15 +866,15 @@ async def get( default: Default value if key not found scope: Optional explicit scope. One of ``"global"``, ``"session"``, ``"actor"``, ``"workflow"``. ``None`` performs hierarchical lookup. - scope_id: Identifier for the scope. Required for ``session``/``actor``/ - ``workflow`` when ``scope`` is set; ignored for ``global``. + scope_id: Optional identifier for the scope. When omitted for + ``session``/``actor``/``workflow``, the current execution context + headers are used; ignored for ``global``. Returns: The stored value or default if not found Raises: - ValueError: If ``scope`` is invalid, or if a non-global scope is given - without a ``scope_id``. + ValueError: If ``scope`` is invalid. MemoryAccessError: If the memory backend request fails. """ if scope is None: @@ -922,12 +916,12 @@ async def delete( key: The memory key scope: Optional explicit scope. One of ``"global"``, ``"session"``, ``"actor"``, ``"workflow"``. ``None`` uses the current scope. - scope_id: Identifier for the scope. Required for ``session``/``actor``/ - ``workflow`` when ``scope`` is set; ignored for ``global``. + scope_id: Optional identifier for the scope. When omitted for + ``session``/``actor``/``workflow``, the current execution context + headers are used; ignored for ``global``. Raises: - ValueError: If ``scope`` is invalid, or if a non-global scope is given - without a ``scope_id``. + ValueError: If ``scope`` is invalid. MemoryAccessError: If the memory backend request fails. """ if scope is None: diff --git a/sdk/python/tests/test_memory_scope_kwargs.py b/sdk/python/tests/test_memory_scope_kwargs.py index 57106c562..85843bdad 100644 --- a/sdk/python/tests/test_memory_scope_kwargs.py +++ b/sdk/python/tests/test_memory_scope_kwargs.py @@ -169,15 +169,31 @@ async def test_invalid_scope_raises_on_delete(interface): @pytest.mark.unit @pytest.mark.asyncio @pytest.mark.parametrize("scope", ["session", "actor", "workflow"]) -async def test_non_global_scope_without_id_raises(interface, scope): - with pytest.raises(ValueError) as exc_info: - await interface.set("k", 1, scope=scope) +async def test_set_scope_without_id_uses_current_context(interface, scope): + await interface.set("k", 1, scope=scope) + interface.memory_client.set.assert_awaited_once_with( + "k", 1, scope=scope, scope_id=None + ) - message = str(exc_info.value) - assert "scope_id" in message - # Points the caller at the accessor equivalent. - assert f"app.memory.{scope}(" in message - interface.memory_client.set.assert_not_awaited() + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize("scope", ["session", "actor", "workflow"]) +async def test_get_scope_without_id_uses_current_context(interface, scope): + await interface.get("k", scope=scope) + interface.memory_client.get.assert_awaited_once_with( + "k", default=None, scope=scope, scope_id=None + ) + + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize("scope", ["session", "actor", "workflow"]) +async def test_delete_scope_without_id_uses_current_context(interface, scope): + await interface.delete("k", scope=scope) + interface.memory_client.delete.assert_awaited_once_with( + "k", scope=scope, scope_id=None + ) @pytest.mark.unit From 01aa019184078043b9c11880f0d8f0a244b73539 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 2 Jul 2026 19:51:59 -0400 Subject: [PATCH 3/3] fix(memory): derive event history scope ids --- sdk/typescript/src/memory/MemoryClient.ts | 2 +- .../src/memory/MemoryEventClient.ts | 6 ++-- .../tests/memory_event_client.test.ts | 28 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/src/memory/MemoryClient.ts b/sdk/typescript/src/memory/MemoryClient.ts index 3ddce2ce4..fb4099252 100644 --- a/sdk/typescript/src/memory/MemoryClient.ts +++ b/sdk/typescript/src/memory/MemoryClient.ts @@ -87,7 +87,7 @@ export class MemoryClientBase { } } - private resolveScopeId(scope?: MemoryScope, scopeId?: string, metadata?: MemoryRequestMetadata) { + protected resolveScopeId(scope?: MemoryScope, scopeId?: string, metadata?: MemoryRequestMetadata) { if (scopeId) return scopeId; switch (scope) { case 'workflow': diff --git a/sdk/typescript/src/memory/MemoryEventClient.ts b/sdk/typescript/src/memory/MemoryEventClient.ts index 597fc2c33..6635d60b2 100644 --- a/sdk/typescript/src/memory/MemoryEventClient.ts +++ b/sdk/typescript/src/memory/MemoryEventClient.ts @@ -120,6 +120,7 @@ export class MemoryEventClient extends MemoryClientBase { limit = 100, scope, scopeId, + metadata, } = options; try { @@ -144,8 +145,9 @@ export class MemoryEventClient extends MemoryClientBase { params.scope = scope; } - if (scopeId) { - params.scope_id = scopeId; + const resolvedScopeId = this.resolveScopeId(scope, scopeId, metadata); + if (resolvedScopeId) { + params.scope_id = resolvedScopeId; } const res = await this.http.get('/api/v1/memory/events/history', { diff --git a/sdk/typescript/tests/memory_event_client.test.ts b/sdk/typescript/tests/memory_event_client.test.ts index d9399b404..a4b255f2f 100644 --- a/sdk/typescript/tests/memory_event_client.test.ts +++ b/sdk/typescript/tests/memory_event_client.test.ts @@ -210,4 +210,32 @@ describe('MemoryEventClient exported methods', () => { await expect(client.history()).resolves.toEqual([]); expect(errorSpy).toHaveBeenCalledWith('Failed to get event history: Error: boom'); }); + + it('derives history scope_id from metadata when scopeId is omitted', async () => { + const client = new MemoryEventClient('http://localhost:8080'); + const http = getHttpClient(); + http.get.mockResolvedValueOnce({ data: [] }); + + await expect( + client.history({ + scope: 'workflow', + metadata: { + workflowId: 'wf-derived', + runId: 'run-fallback' + } + }) + ).resolves.toEqual([]); + + expect(http.get).toHaveBeenCalledWith('/api/v1/memory/events/history', { + params: { + limit: 100, + scope: 'workflow', + scope_id: 'wf-derived' + }, + headers: { + 'X-Workflow-ID': 'wf-derived', + 'X-Run-ID': 'run-fallback' + } + }); + }); });