diff --git a/README.md b/README.md index 111e116ba..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) | — | — | — | **●** | @@ -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..c03fabee0 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,113 @@ 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 may use scope_id or the +# current execution context headers already carried by MemoryClient. +_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: 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. + """ + 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 + + 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: 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. 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 +841,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: 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. 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 +896,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: 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. 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..85843bdad --- /dev/null +++ b/sdk/python/tests/test_memory_scope_kwargs.py @@ -0,0 +1,206 @@ +"""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_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 + ) + + +@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 +@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" + ) 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' + } + }); + }); });