From 6df83799c00488ab5cd1b0c9482ea00e067e8715 Mon Sep 17 00:00:00 2001 From: foleydang Date: Fri, 16 Oct 2026 16:55:17 +0800 Subject: [PATCH] feat(agentstudio): memory stores, memories and memory versions Add the memory resource family mirroring the Claude Managed Agents client.beta.memory_stores surface (bailian-app-control MemoryStoreController API): - MemoryStores CRUD + archive, nested memories (create/list/retrieve/ update/delete with optimistic lock) and memory_versions (list/retrieve/redact), sync + async - Memories/MemoryStore/MemoryVersion models; params with dotted wire keys (created_at.gte); typed ConflictError 409 codes preserved on the unified status-based exception hierarchy - CursorPage gains a forward-compat extra bag (build_page preserves unknown envelope keys for every list resource) and a typed prefixes property; depth >= 2 is derived client-side from the flat listing (the backend only implements depth 0/1) - SessionResource gains the memory_store fields (memory_store_id, access, instructions); sample and 24 unit tests Co-Authored-By: Claude Code --- dashscope/agentstudio/__init__.py | 6 + dashscope/agentstudio/client.py | 6 + dashscope/agentstudio/pagination.py | 43 +- dashscope/agentstudio/resources/__init__.py | 14 + dashscope/agentstudio/resources/_helpers.py | 47 ++ .../agentstudio/resources/memory_stores.py | 790 ++++++++++++++++++ dashscope/agentstudio/types/__init__.py | 10 + dashscope/agentstudio/types/models.py | 91 ++ dashscope/agentstudio/types/params.py | 136 +++ samples/test_agentstudio.py | 49 ++ tests/unit/test_agentstudio_memory_stores.py | 513 ++++++++++++ 11 files changed, 1702 insertions(+), 3 deletions(-) create mode 100644 dashscope/agentstudio/resources/memory_stores.py create mode 100644 tests/unit/test_agentstudio_memory_stores.py diff --git a/dashscope/agentstudio/__init__.py b/dashscope/agentstudio/__init__.py index 90932494..edd58186 100644 --- a/dashscope/agentstudio/__init__.py +++ b/dashscope/agentstudio/__init__.py @@ -51,6 +51,9 @@ CursorPage, ) from .types import ( + Memory, + MemoryStore, + MemoryVersion, Message, PermissionPolicy, SecurityAlert, @@ -117,6 +120,9 @@ "CursorPage", "AsyncCursorPage", # unified message type + "Memory", + "MemoryStore", + "MemoryVersion", "Message", "PermissionPolicy", "SecurityAlert", diff --git a/dashscope/agentstudio/client.py b/dashscope/agentstudio/client.py index 6482d8d4..be840af8 100644 --- a/dashscope/agentstudio/client.py +++ b/dashscope/agentstudio/client.py @@ -21,6 +21,10 @@ AsyncEnvironments, ) from dashscope.agentstudio.resources.files import Files, AsyncFiles +from dashscope.agentstudio.resources.memory_stores import ( + MemoryStores, + AsyncMemoryStores, +) from dashscope.agentstudio.resources.security import Security, AsyncSecurity from dashscope.agentstudio.resources.sessions import Sessions, AsyncSessions from dashscope.agentstudio.resources.skills import Skills, AsyncSkills @@ -144,6 +148,7 @@ def __init__( self.files = Files(self) self.skills = Skills(self) self.vaults = Vaults(self) + self.memory_stores = MemoryStores(self) self.webhook_endpoints = WebhookEndpoints(self) self.security = Security(self) @@ -218,6 +223,7 @@ def __init__( self.files = AsyncFiles(self) self.skills = AsyncSkills(self) self.vaults = AsyncVaults(self) + self.memory_stores = AsyncMemoryStores(self) self.webhook_endpoints = AsyncWebhookEndpoints(self) self.security = AsyncSecurity(self) diff --git a/dashscope/agentstudio/pagination.py b/dashscope/agentstudio/pagination.py index 82771285..8e1eb508 100644 --- a/dashscope/agentstudio/pagination.py +++ b/dashscope/agentstudio/pagination.py @@ -25,7 +25,13 @@ class CursorPage(Generic[T]): - """Synchronous page over an opaque ``page`` cursor.""" + """Synchronous page over an opaque ``page`` cursor. + + ``extra`` preserves any additional top-level fields the list + envelope carried beyond ``data`` / ``next_page`` (forward + compatibility when the server adds fields); ``extra`` is per page + and does not participate in auto-pagination. + """ def __init__( self, @@ -34,11 +40,13 @@ def __init__( next_page: Optional[str], request_id: Optional[str], fetch_next: Optional[Callable[[str], "CursorPage[T]"]] = None, + extra: Optional[Dict[str, Any]] = None, ) -> None: self.data = data self.next_page = next_page self.request_id = request_id self._fetch_next = fetch_next + self.extra: Dict[str, Any] = dict(extra or {}) def has_next(self) -> bool: return bool(self.next_page) and self._fetch_next is not None @@ -66,9 +74,20 @@ def __len__(self) -> int: """ return len(self.data) + @property + def prefixes(self) -> List[str]: + """Directory prefixes of a tree-style listing (memory only).""" + + raw = self.extra.get("prefixes") or [] + return list(raw) if isinstance(raw, (list, tuple)) else [] + class AsyncCursorPage(Generic[T]): - """Asynchronous page over an opaque ``page`` cursor.""" + """Asynchronous page over an opaque ``page`` cursor. + + See :class:`CursorPage` for the ``extra`` forward-compatibility + bag. + """ def __init__( self, @@ -77,11 +96,13 @@ def __init__( next_page: Optional[str], request_id: Optional[str], fetch_next: Optional[Callable[[str], "AsyncCursorPage[T]"]] = None, + extra: Optional[Dict[str, Any]] = None, ) -> None: self.data = data self.next_page = next_page self.request_id = request_id self._fetch_next = fetch_next + self.extra: Dict[str, Any] = dict(extra or {}) def has_next(self) -> bool: return bool(self.next_page) and self._fetch_next is not None @@ -115,6 +136,13 @@ def __len__(self) -> int: """ return len(self.data) + @property + def prefixes(self) -> List[str]: + """Directory prefixes of a tree-style listing (memory only).""" + + raw = self.extra.get("prefixes") or [] + return list(raw) if isinstance(raw, (list, tuple)) else [] + def build_page( *, @@ -124,16 +152,25 @@ def build_page( request_id: Optional[str] = None, page_cls: type = CursorPage, ) -> Any: - """Hydrate a page object from a normalized payload.""" + """Hydrate a page object from a normalized payload. + + Envelope fields beyond ``data`` / ``next_page`` are preserved on + the page's ``extra`` forward-compatibility bag (mirrors + ``BaseModel.extra``). + """ items_raw = payload.get("data") or [] items: List[T] = [ item_factory(it) for it in items_raw if isinstance(it, dict) ] next_page = payload.get("next_page") + extra = { + k: v for k, v in payload.items() if k not in ("data", "next_page") + } return page_cls( data=items, next_page=next_page, request_id=request_id, fetch_next=fetch_next, + extra=extra, ) diff --git a/dashscope/agentstudio/resources/__init__.py b/dashscope/agentstudio/resources/__init__.py index 0e22c24f..76c65123 100644 --- a/dashscope/agentstudio/resources/__init__.py +++ b/dashscope/agentstudio/resources/__init__.py @@ -27,6 +27,14 @@ AsyncEnvironments, ) from dashscope.agentstudio.resources.files import Files, AsyncFiles +from dashscope.agentstudio.resources.memory_stores import ( + AsyncMemories, + AsyncMemoryStores, + AsyncMemoryVersions, + Memories, + MemoryStores, + MemoryVersions, +) from dashscope.agentstudio.resources.session_events import ( AsyncSessionEvents, SessionEvents, @@ -74,6 +82,12 @@ "AsyncSessionEvents", "Vaults", "AsyncVaults", + "MemoryStores", + "AsyncMemoryStores", + "Memories", + "AsyncMemories", + "MemoryVersions", + "AsyncMemoryVersions", "WebhookEndpoints", "AsyncWebhookEndpoints", "_TypedEventStream", diff --git a/dashscope/agentstudio/resources/_helpers.py b/dashscope/agentstudio/resources/_helpers.py index 3653ce7a..f47d6d94 100644 --- a/dashscope/agentstudio/resources/_helpers.py +++ b/dashscope/agentstudio/resources/_helpers.py @@ -14,6 +14,9 @@ DeploymentRun, Environment, File, + Memory, + MemoryStore, + MemoryVersion, ServerEvent, Session, SessionResource, @@ -103,6 +106,18 @@ def _coerce_deployment_run(payload: Mapping[str, Any]) -> DeploymentRun: return DeploymentRun(**dict(payload)) +def _coerce_memory_store(payload: Mapping[str, Any]) -> MemoryStore: + return MemoryStore(**dict(payload)) + + +def _coerce_memory(payload: Mapping[str, Any]) -> Memory: + return Memory(**dict(payload)) + + +def _coerce_memory_version(payload: Mapping[str, Any]) -> MemoryVersion: + return MemoryVersion(**dict(payload)) + + # --------------------------------------------------------------------------- # Path helpers # --------------------------------------------------------------------------- @@ -138,3 +153,35 @@ def _session_thread_events_path(session_id: str, thread_id: str) -> str: def _session_thread_archive_path(session_id: str, thread_id: str) -> str: return f"/sessions/{session_id}/threads/{thread_id}/archive" + + +def _memory_stores_path() -> str: + return "/memory_stores" + + +def _memory_store_item_path(store_id: str) -> str: + return f"/memory_stores/{store_id}" + + +def _memory_store_archive_path(store_id: str) -> str: + return f"/memory_stores/{store_id}/archive" + + +def _memories_path(store_id: str) -> str: + return f"/memory_stores/{store_id}/memories" + + +def _memory_item_path(store_id: str, memory_id: str) -> str: + return f"/memory_stores/{store_id}/memories/{memory_id}" + + +def _memory_versions_path(store_id: str) -> str: + return f"/memory_stores/{store_id}/memory_versions" + + +def _memory_version_item_path(store_id: str, version_id: str) -> str: + return f"/memory_stores/{store_id}/memory_versions/{version_id}" + + +def _memory_version_redact_path(store_id: str, version_id: str) -> str: + return f"/memory_stores/{store_id}/memory_versions/{version_id}/redact" diff --git a/dashscope/agentstudio/resources/memory_stores.py b/dashscope/agentstudio/resources/memory_stores.py new file mode 100644 index 00000000..573084a0 --- /dev/null +++ b/dashscope/agentstudio/resources/memory_stores.py @@ -0,0 +1,790 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Memory stores resource classes. + +A memory store is a workspace-scoped collection of small text +documents that persists across sessions. Stores are attached to +sessions via the session ``resources[]`` array and mounted into the +session container at ``/mnt/memory//``. + +Object model:: + + memory_stores (memstore_...) workspace container + .memories (mem_...) one text file per path + .memory_versions (memver_...) immutable snapshots + +Follows the Claude Managed Agents ``client.beta.memory_stores`` SDK +surface, with these verified divergences (the backend is +bailian-app-control ``MemoryStoreController`` over bailian-app-context +OTS storage): + +* updates use POST; the optimistic-lock hash rides the top-level + ``content_sha256`` body field (CMA uses PATCH with a + ``precondition`` object); +* store ``metadata`` on update is replaced wholesale; CMA merge-patches + it; +* ``view="full"`` caps ``limit`` at 20 and rejects higher values with + 400; CMA silently clamps; +* list filter keys use dotted wire names (``created_at.gte``), and + directory nodes come back as a separate ``prefixes`` page field + (``page.prefixes``, mirrored by ``page.getPrefixes()`` in the Java + SDK) instead of inline ``memory_prefix`` items; +* depth accepts 0/1 natively; larger values are derived client-side + (see ``_MAX_WIRE_DEPTH``); +* create and update responses never include ``content``; +* no store-list time filters, no conditional memory delete, no version + attribution fields or filters — CMA has all of them (unknown filters + are silently ignored). +""" + +from __future__ import annotations + +from typing import Any, List, Mapping, Optional + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) +from dashscope.agentstudio.resources._helpers import ( + _coerce_memory, + _coerce_memory_store, + _coerce_memory_version, + _memories_path, + _memory_item_path, + _memory_stores_path, + _memory_store_archive_path, + _memory_store_item_path, + _memory_version_item_path, + _memory_version_redact_path, + _memory_versions_path, +) +from dashscope.agentstudio.types import ( + DeleteResponse, + Memory, + MemoryStore, + MemoryVersion, +) +from dashscope.agentstudio.types.params import ( + MemoryCreateParams, + MemoryListParams, + MemoryStoreCreateParams, + MemoryStoreListParams, + MemoryStoreUpdateParams, + MemoryUpdateParams, + MemoryVersionListParams, +) + + +# --------------------------------------------------------------------------- +# Memories (sub-resource of MemoryStores) +# --------------------------------------------------------------------------- + +# The backend accepts only depth null/0 (flat) or 1 (single-level tree); +# anything else returns 400 (CMA likewise defines only 0/1). depth >= 2 is +# this SDK's own generalization over the flat listing. +_MAX_WIRE_DEPTH = 1 + + +def _apply_depth_emulation( + page: Any, + depth: int, + path_prefix: Optional[str], +) -> Any: + """Filter a flat page into a depth-bounded tree view. + + Drops files more than ``depth`` segments below ``path_prefix`` + and synthesizes directory prefixes (cut files still contribute + their frontier directory). Both are per page. + """ + + base = path_prefix or "/" + if not base.endswith("/"): + base += "/" + kept: List[Any] = [] + dirs: set = set() + for item in page.data: + path = item.path or "" + rel = path[len(base) :] if path.startswith(base) else path.lstrip("/") + segs = [s for s in rel.split("/") if s] + if len(segs) <= depth: + kept.append(item) + for lvl in range(1, min(len(segs) - 1, depth) + 1): + dirs.add(base + "/".join(segs[:lvl]) + "/") + page.data = kept + page.extra["prefixes"] = sorted(dirs) + return page + + +class Memories: + def __init__(self, client) -> None: + self._client = client + + def create( + self, + store_id: str, + *, + path: str, + content: str, + ) -> Memory: + """Create a memory at ``path`` (``POST .../memories``). + + A memory already existing at ``path`` returns 409 + ``memory_path_conflict_error``. + """ + body = MemoryCreateParams(path=path, content=content).to_dict() + resp = self._client.transport.request( + "POST", + _memories_path(store_id), + json=body, + ) + return _coerce_memory(resp.data) + + def list( + self, + store_id: str, + *, + path_prefix: Optional[str] = None, + depth: Optional[int] = None, + view: Optional[str] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[Memory]: + """List memories (``GET .../memories``). + + ``path_prefix`` scopes the listing (server-validated: must + start and end with ``/`` — segment-aligned, so ``/notes/`` + matches ``/notes/a.md`` but not ``/notes_backup/old.md``; a + bare ``"/"`` lists the whole store). File items land in + ``data``; directory nodes come back as ``page.prefixes`` + (per page; merge across pages manually for a full tree). + ``view="full"`` includes ``content`` per item but caps + ``limit`` at 20 — the backend rejects higher values with + 400 (CMA silently clamps); the default ``"basic"`` returns + metadata only with ``limit`` up to 100. + + ``depth`` bounds the tree walk below ``path_prefix``; the + backend implements 0/1 natively and larger values are derived + client-side (see ``_MAX_WIRE_DEPTH``). With ``view="full"`` the + derivation still transfers the content of files the depth cut + discards — prefer ``view="basic"`` for deep walks. + """ + depth_emulated = depth is not None and depth > _MAX_WIRE_DEPTH + wire_depth = None if depth_emulated else depth + params = MemoryListParams( + path_prefix=path_prefix, + depth=wire_depth, + view=view, + limit=limit, + page=page, + ).to_dict() + resp = self._client.transport.request( + "GET", + _memories_path(store_id), + params=params, + ) + result = build_page( + payload=resp.data, + item_factory=_coerce_memory, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + store_id, + path_prefix=path_prefix, + depth=depth, + view=view, + limit=limit, + page=nxt, + ), + ) + if depth_emulated: + result = _apply_depth_emulation(result, depth, path_prefix) + return result + + def retrieve( + self, + store_id: str, + memory_id: str, + ) -> Memory: + """Retrieve a memory with content (``GET .../memories/{id}``).""" + resp = self._client.transport.request( + "GET", + _memory_item_path(store_id, memory_id), + ) + return _coerce_memory(resp.data) + + get = retrieve # type: ignore[assignment] + + def update( + self, + store_id: str, + memory_id: str, + *, + path: Optional[str] = None, + content: Optional[str] = None, + content_sha256: Optional[str] = None, + ) -> Memory: + """Update a memory (``POST .../memories/{id}``). + + Mutate ``content``, rename via ``path``, or both — at least one + must be given. Pass the sha from the previously retrieved + memory as ``content_sha256`` for optimistic concurrency; a + mismatch returns 409 ``memory_precondition_failed_error``. + """ + if path is None and content is None: + raise TypeError( + "memories.update() requires at least one of " + "'path' or 'content'", + ) + body = MemoryUpdateParams( + path=path, + content=content, + content_sha256=content_sha256, + ).to_dict() + resp = self._client.transport.request( + "POST", + _memory_item_path(store_id, memory_id), + json=body, + ) + return _coerce_memory(resp.data) + + def delete( + self, + store_id: str, + memory_id: str, + ) -> DeleteResponse: + """Delete a memory (``DELETE .../memories/{id}``).""" + resp = self._client.transport.request( + "DELETE", + _memory_item_path(store_id, memory_id), + ) + return DeleteResponse(**resp.data) + + +# --------------------------------------------------------------------------- +# MemoryVersions (sub-resource of MemoryStores) +# --------------------------------------------------------------------------- + + +class MemoryVersions: + def __init__(self, client) -> None: + self._client = client + + def list( + self, + store_id: str, + *, + memory_id: Optional[str] = None, + operation: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lte: Optional[str] = None, + view: Optional[str] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[MemoryVersion]: + """List versions, newest-first (``GET .../memory_versions``). + + Filters: ``memory_id``, ``operation`` (``created`` / ``modified`` + / ``deleted``), ``created_at_gte`` / ``created_at_lte`` (ISO-8601 + timestamps; sent on the wire as ``created_at.gte`` / + ``created_at.lte``), and ``view`` (``basic`` / ``full``). + """ + params = MemoryVersionListParams( + memory_id=memory_id, + operation=operation, + created_at_gte=created_at_gte, + created_at_lte=created_at_lte, + view=view, + limit=limit, + page=page, + ).to_dict() + resp = self._client.transport.request( + "GET", + _memory_versions_path(store_id), + params=params, + ) + return build_page( + payload=resp.data, + item_factory=_coerce_memory_version, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + store_id, + memory_id=memory_id, + operation=operation, + created_at_gte=created_at_gte, + created_at_lte=created_at_lte, + view=view, + limit=limit, + page=nxt, + ), + ) + + def retrieve( + self, + store_id: str, + version_id: str, + ) -> MemoryVersion: + """Retrieve a version snapshot (``GET .../memory_versions/{id}``).""" + resp = self._client.transport.request( + "GET", + _memory_version_item_path(store_id, version_id), + ) + return _coerce_memory_version(resp.data) + + get = retrieve # type: ignore[assignment] + + def redact( + self, + store_id: str, + version_id: str, + ) -> MemoryVersion: + """Redact a version (``POST .../memory_versions/{id}/redact``). + + Scrubs ``path`` / ``content`` / ``content_sha256`` / + ``content_size_bytes`` while preserving the audit trail + (``created_at``, ids). Idempotent and irreversible. + """ + resp = self._client.transport.request( + "POST", + _memory_version_redact_path(store_id, version_id), + ) + return _coerce_memory_version(resp.data) + + +# --------------------------------------------------------------------------- +# MemoryStores (parent resource) +# --------------------------------------------------------------------------- + + +class MemoryStores: + def __init__(self, client) -> None: + self._client = client + self.memories = Memories(client) + self.memory_versions = MemoryVersions(client) + + def create( + self, + *, + name: str, + description: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> MemoryStore: + """Create a memory store (``POST /memory_stores``). + + ``description`` is passed to the agent so it knows what the + store contains — write it for the model, not for humans. + """ + body = MemoryStoreCreateParams( + name=name, + description=description, + metadata=dict(metadata) if metadata is not None else None, + ).to_dict() + resp = self._client.transport.request( + "POST", + _memory_stores_path(), + json=body, + ) + return _coerce_memory_store(resp.data) + + def retrieve(self, store_id: str) -> MemoryStore: + resp = self._client.transport.request( + "GET", + _memory_store_item_path(store_id), + ) + return _coerce_memory_store(resp.data) + + get = retrieve # type: ignore[assignment] + + def update( + self, + store_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> MemoryStore: + """Update a store (``POST /memory_stores/{store_id}``). + + Mutate ``name`` / ``description`` / ``metadata`` — at least one + must be given. ``metadata`` is **replaced wholesale** (unlike + CMA's merge-patch): keys not included in the request are + dropped; a key set to ``null`` deletes it. + """ + if name is None and description is None and metadata is None: + raise TypeError( + "memory_stores.update() requires at least one of " + "'name', 'description' or 'metadata'", + ) + body = MemoryStoreUpdateParams( + name=name, + description=description, + metadata=dict(metadata) if metadata is not None else None, + ).to_dict() + resp = self._client.transport.request( + "POST", + _memory_store_item_path(store_id), + json=body, + ) + return _coerce_memory_store(resp.data) + + def list( + self, + *, + include_archived: Optional[bool] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[MemoryStore]: + """List stores (``GET /memory_stores``). + + The public API has no ``keyword`` filter (that is a + console-only capability). + """ + params = MemoryStoreListParams( + include_archived=include_archived, + limit=limit, + page=page, + ).to_dict() + resp = self._client.transport.request( + "GET", + _memory_stores_path(), + params=params, + ) + return build_page( + payload=resp.data, + item_factory=_coerce_memory_store, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + include_archived=include_archived, + limit=limit, + page=nxt, + ), + ) + + def delete(self, store_id: str) -> DeleteResponse: + """Delete a store (``DELETE /memory_stores/{id}``). + + Accepted immediately; items are removed asynchronously. The + response is a marker (``type="memory_store_deleted"``). + """ + resp = self._client.transport.request( + "DELETE", + _memory_store_item_path(store_id), + ) + return DeleteResponse(**resp.data) + + def archive(self, store_id: str) -> MemoryStore: + """Archive a store (``POST /memory_stores/{id}/archive``). + + Read-only afterwards: existing session attachments continue, + new sessions cannot reference it. Irreversible. + """ + resp = self._client.transport.request( + "POST", + _memory_store_archive_path(store_id), + ) + return _coerce_memory_store(resp.data) + + +# --------------------------------------------------------------------------- +# Async variants +# --------------------------------------------------------------------------- + + +class AsyncMemories: + def __init__(self, client) -> None: + self._client = client + + async def create( + self, + store_id: str, + *, + path: str, + content: str, + ) -> Memory: + body = MemoryCreateParams(path=path, content=content).to_dict() + resp = await self._client.transport.request( + "POST", + _memories_path(store_id), + json=body, + ) + return _coerce_memory(resp.data) + + async def list( + self, + store_id: str, + *, + path_prefix: Optional[str] = None, + depth: Optional[int] = None, + view: Optional[str] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[Memory]: + """Async variant of :meth:`Memories.list` (depth >= 2 is + emulated client-side the same way).""" + depth_emulated = depth is not None and depth > _MAX_WIRE_DEPTH + wire_depth = None if depth_emulated else depth + params = MemoryListParams( + path_prefix=path_prefix, + depth=wire_depth, + view=view, + limit=limit, + page=page, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _memories_path(store_id), + params=params, + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[Memory]: + return await self.list( + store_id, + path_prefix=path_prefix, + depth=depth, + view=view, + limit=limit, + page=nxt, + ) + + result = build_page( + payload=resp.data, + item_factory=_coerce_memory, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + if depth_emulated: + result = _apply_depth_emulation(result, depth, path_prefix) + return result + + async def retrieve( + self, + store_id: str, + memory_id: str, + ) -> Memory: + resp = await self._client.transport.request( + "GET", + _memory_item_path(store_id, memory_id), + ) + return _coerce_memory(resp.data) + + get = retrieve # type: ignore[assignment] + + async def update( + self, + store_id: str, + memory_id: str, + *, + path: Optional[str] = None, + content: Optional[str] = None, + content_sha256: Optional[str] = None, + ) -> Memory: + if path is None and content is None: + raise TypeError( + "memories.update() requires at least one of " + "'path' or 'content'", + ) + body = MemoryUpdateParams( + path=path, + content=content, + content_sha256=content_sha256, + ).to_dict() + resp = await self._client.transport.request( + "POST", + _memory_item_path(store_id, memory_id), + json=body, + ) + return _coerce_memory(resp.data) + + async def delete( + self, + store_id: str, + memory_id: str, + ) -> DeleteResponse: + resp = await self._client.transport.request( + "DELETE", + _memory_item_path(store_id, memory_id), + ) + return DeleteResponse(**resp.data) + + +class AsyncMemoryVersions: + def __init__(self, client) -> None: + self._client = client + + async def list( + self, + store_id: str, + *, + memory_id: Optional[str] = None, + operation: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lte: Optional[str] = None, + view: Optional[str] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[MemoryVersion]: + params = MemoryVersionListParams( + memory_id=memory_id, + operation=operation, + created_at_gte=created_at_gte, + created_at_lte=created_at_lte, + view=view, + limit=limit, + page=page, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _memory_versions_path(store_id), + params=params, + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[MemoryVersion]: + return await self.list( + store_id, + memory_id=memory_id, + operation=operation, + created_at_gte=created_at_gte, + created_at_lte=created_at_lte, + view=view, + limit=limit, + page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_memory_version, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def retrieve( + self, + store_id: str, + version_id: str, + ) -> MemoryVersion: + resp = await self._client.transport.request( + "GET", + _memory_version_item_path(store_id, version_id), + ) + return _coerce_memory_version(resp.data) + + get = retrieve # type: ignore[assignment] + + async def redact( + self, + store_id: str, + version_id: str, + ) -> MemoryVersion: + resp = await self._client.transport.request( + "POST", + _memory_version_redact_path(store_id, version_id), + ) + return _coerce_memory_version(resp.data) + + +class AsyncMemoryStores: + def __init__(self, client) -> None: + self._client = client + self.memories = AsyncMemories(client) + self.memory_versions = AsyncMemoryVersions(client) + + async def create( + self, + *, + name: str, + description: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> MemoryStore: + body = MemoryStoreCreateParams( + name=name, + description=description, + metadata=dict(metadata) if metadata is not None else None, + ).to_dict() + resp = await self._client.transport.request( + "POST", + _memory_stores_path(), + json=body, + ) + return _coerce_memory_store(resp.data) + + async def retrieve(self, store_id: str) -> MemoryStore: + resp = await self._client.transport.request( + "GET", + _memory_store_item_path(store_id), + ) + return _coerce_memory_store(resp.data) + + get = retrieve # type: ignore[assignment] + + async def update( + self, + store_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> MemoryStore: + if name is None and description is None and metadata is None: + raise TypeError( + "memory_stores.update() requires at least one of " + "'name', 'description' or 'metadata'", + ) + body = MemoryStoreUpdateParams( + name=name, + description=description, + metadata=dict(metadata) if metadata is not None else None, + ).to_dict() + resp = await self._client.transport.request( + "POST", + _memory_store_item_path(store_id), + json=body, + ) + return _coerce_memory_store(resp.data) + + async def list( + self, + *, + include_archived: Optional[bool] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[MemoryStore]: + params = MemoryStoreListParams( + include_archived=include_archived, + limit=limit, + page=page, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _memory_stores_path(), + params=params, + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[MemoryStore]: + return await self.list( + include_archived=include_archived, + limit=limit, + page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_memory_store, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def delete(self, store_id: str) -> DeleteResponse: + resp = await self._client.transport.request( + "DELETE", + _memory_store_item_path(store_id), + ) + return DeleteResponse(**resp.data) + + async def archive(self, store_id: str) -> MemoryStore: + resp = await self._client.transport.request( + "POST", + _memory_store_archive_path(store_id), + ) + return _coerce_memory_store(resp.data) diff --git a/dashscope/agentstudio/types/__init__.py b/dashscope/agentstudio/types/__init__.py index 3148a6f8..9cd37476 100644 --- a/dashscope/agentstudio/types/__init__.py +++ b/dashscope/agentstudio/types/__init__.py @@ -32,6 +32,9 @@ MultiAgentRosterEntry, Credential, CredentialAuth, + Memory, + MemoryStore, + MemoryVersion, Deployment, DeploymentAgentReference, DeploymentError, @@ -113,6 +116,13 @@ CredentialCreateParams, CredentialUpdateParams, CredentialListParams, + MemoryStoreCreateParams, + MemoryStoreUpdateParams, + MemoryStoreListParams, + MemoryCreateParams, + MemoryUpdateParams, + MemoryListParams, + MemoryVersionListParams, WebhookEndpointCreateParams, WebhookEndpointUpdateParams, WebhookEventListParams, diff --git a/dashscope/agentstudio/types/models.py b/dashscope/agentstudio/types/models.py index e043f10b..f56ef695 100644 --- a/dashscope/agentstudio/types/models.py +++ b/dashscope/agentstudio/types/models.py @@ -141,12 +141,25 @@ class SessionResource(BaseModel): source file id passed at mount time. ``mount_path`` is the absolute sandbox path (the server prepends a prefix; the user-supplied path is preserved losslessly and must be under ``/uploads/``). + + For ``type="memory_store"`` resources the memory-specific fields + apply: ``memory_store_id`` (the attached store), ``access`` + (``read_write`` default / ``read_only``), ``instructions`` + (per-session guidance, <= 4096 chars) and the server-snapshotted + ``name``/``description`` of the store at attach time. Memory + stores can only be attached at session creation time — + ``sessions.resources.add`` accepts files only. """ _fields = ( "id", "type", "file_id", + "memory_store_id", + "access", + "instructions", + "name", + "description", "mount_path", "created_at", "updated_at", @@ -1447,6 +1460,84 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) +# =========================================================================== +# Section 6b – Memory stores (workspace-persistent memory) +# =========================================================================== + + +class MemoryStore(BaseModel): + """A workspace-scoped store of small text documents. + + Stores persist across sessions and are mounted into a session + container at ``/mnt/memory//`` when attached via the session + ``resources[]`` array. ``archived_at`` is set once the store is + archived: read-only afterwards, existing session attachments keep + working, but new sessions cannot reference it. + """ + + _fields = ( + "id", + "archived_at", + "created_at", + "description", + "metadata", + "name", + "type", + "updated_at", + "request_id", + ) + + +class Memory(BaseModel): + """One text document inside a memory store, addressed by ``path``. + + ``content`` appears only in retrieve responses and in list + responses with ``view="full"``; create and update never return it. + ``content_sha256`` identifies the content revision — pass it back + to ``memories.update`` for optimistic concurrency (a mismatch + returns 409 ``memory_precondition_failed_error``). + """ + + _fields = ( + "id", + "content", + "content_sha256", + "content_size_bytes", + "created_at", + "memory_store_id", + "memory_version_id", + "path", + "type", + "updated_at", + "request_id", + ) + + +class MemoryVersion(BaseModel): + """Immutable snapshot produced by every memory mutation; versions + are returned newest-first. + + ``operation`` is ``created`` / ``modified`` / ``deleted``. After + ``redact``, ``path``, ``content``, ``content_sha256`` and + ``content_size_bytes`` are cleared; the audit trail is preserved. + """ + + _fields = ( + "id", + "content", + "content_sha256", + "content_size_bytes", + "created_at", + "memory_id", + "memory_store_id", + "operation", + "path", + "redacted_at", + "type", + "request_id", + ) + + # =========================================================================== # Section 7 – Backward compatibility aliases # =========================================================================== diff --git a/dashscope/agentstudio/types/params.py b/dashscope/agentstudio/types/params.py index 2716b36e..d85faa63 100644 --- a/dashscope/agentstudio/types/params.py +++ b/dashscope/agentstudio/types/params.py @@ -910,6 +910,142 @@ def __init__( super().__init__(**kwargs) +# =========================================================================== +# Memory stores +# =========================================================================== + + +class MemoryStoreCreateParams(BaseModel): + """Request body for ``POST /memory_stores``. + + ``name`` must contain 1..255 characters; names may repeat within + a workspace, and the session mount slug ``/mnt/memory//`` is + derived from it. ``description`` is passed to the agent — write it + for the model, not for humans. + """ + + _fields = ("name", "description", "metadata") + + +class MemoryStoreUpdateParams(BaseModel): + """Request body for ``POST /memory_stores/{store_id}``. + + ``metadata`` is replaced wholesale (unlike CMA's merge-patch): + keys not included here are dropped; a key set to ``null`` deletes + it. To change one key, re-send the whole mapping. + """ + + _fields = ("name", "description", "metadata") + + +class MemoryStoreListParams(BaseModel): + """Query params for ``GET /memory_stores``. + + Note: unlike the console API, the public API has no ``keyword`` + filter. + """ + + _fields = ("include_archived", "limit", "page") + + def __init__( + self, + *, + include_archived: Optional[bool] = None, + **kwargs: Any, + ) -> None: + if include_archived is not None: + kwargs["include_archived"] = str( + include_archived, + ).lower() + super().__init__(**kwargs) + + +# =========================================================================== +# Memories +# =========================================================================== + + +class MemoryCreateParams(BaseModel): + """Request body for ``POST /memory_stores/{store_id}/memories``. + + ``path`` rules (enforced server-side, case-sensitive): POSIX style + with a leading ``/`` and no trailing ``/``; 2..1024 UTF-8 bytes; + NFC-normalized; every segment non-empty and not ``.`` or ``..``; + no control / format / line- or paragraph-separator code points. + ``content`` is UTF-8 text, <= 102400 bytes. A memory already + existing at ``path`` returns 409 ``memory_path_conflict_error`` + (the same rules apply to a rename via ``memories.update``). + """ + + _fields = ("path", "content") + + +class MemoryUpdateParams(BaseModel): + """Request body for ``POST /memory_stores/{store_id}/memories/{memory_id}``. + + At least one of ``path`` / ``content`` must be given. Passing the + current ``content_sha256`` (from the retrieved memory) enables + optimistic concurrency — a mismatch returns 409 + ``memory_precondition_failed_error``. + """ + + _fields = ("path", "content", "content_sha256") + + +class MemoryListParams(BaseModel): + """Query params for ``GET /memory_stores/{store_id}/memories``. + + ``path_prefix`` scopes the listing (server-validated: must start + and end with ``/`` — ``"/notes/"`` matches ``/notes/a.md`` but not + ``/notes_backup/old.md``; a bare ``"/"`` lists the whole store). + ``depth`` is 0/absent (flat) or 1 (single-level tree) on the wire; + ``view`` is ``basic`` (metadata only, the default, ``limit`` up to + 100) or ``full`` (include ``content``, ``limit`` capped at 20 — + the backend rejects higher values with 400). + """ + + _fields = ("path_prefix", "depth", "view", "limit", "page") + + +# =========================================================================== +# Memory versions +# =========================================================================== + + +class MemoryVersionListParams(BaseModel): + """Query params for ``GET /memory_stores/{store_id}/memory_versions``. + + Filters: ``memory_id``, ``operation`` (``created`` / ``modified`` / + ``deleted``), ``created_at_gte`` / ``created_at_lte`` (ISO-8601 + timestamps; sent on the wire as ``created_at.gte`` / + ``created_at.lte``), ``view`` (``basic`` / ``full``). Versions are + returned newest-first. + """ + + _fields = ( + "memory_id", + "operation", + "created_at.gte", + "created_at.lte", + "view", + "limit", + "page", + ) + + def __init__( + self, + *, + created_at_gte: Optional[str] = None, + created_at_lte: Optional[str] = None, + **kwargs: Any, + ) -> None: + if created_at_gte is not None: + kwargs["created_at.gte"] = created_at_gte + if created_at_lte is not None: + kwargs["created_at.lte"] = created_at_lte + super().__init__(**kwargs) + + # =========================================================================== # Webhook endpoints # =========================================================================== diff --git a/samples/test_agentstudio.py b/samples/test_agentstudio.py index 03d77f4a..1e3a2b4f 100644 --- a/samples/test_agentstudio.py +++ b/samples/test_agentstudio.py @@ -61,6 +61,55 @@ def test_quickstart(): client.sessions.archive(session.id) print("done.") + @staticmethod + def test_memory_stores(): + """Memory store flow: create store -> seed memory -> read back + -> audit versions -> cleanup.""" + api_key = os.environ["DASHSCOPE_API_KEY"] + workspace = os.environ.get("DASHSCOPE_WORKSPACE", "trial") + client = Client(api_key=api_key, workspace=workspace) + + print("==> creating memory store") + store = client.memory_stores.create( + name="demo-preferences", + description="Per-user preferences and project context.", + ) + print(f" memory_store_id={store.id}") + + print("==> seeding a memory") + mem = client.memory_stores.memories.create( + store.id, + path="/formatting_standards.md", + content="All reports use GAAP formatting.", + ) + print(f" memory_id={mem.id} size={mem.content_size_bytes}") + + print("==> listing memories") + page = client.memory_stores.memories.list(store.id, view="full") + for item in page: + print(f" {item.path} -> {item.content}") + # Directory nodes ride the page's prefixes field (per page): + print(f" prefixes={page.prefixes}") + + print("==> updating with optimistic lock") + updated = client.memory_stores.memories.update( + store.id, + mem.id, + content="All reports use GAAP formatting. ISO-8601 dates.", + content_sha256=mem.content_sha256, + ) + print(f" new sha={updated.content_sha256[:12]}...") + + print("==> listing versions") + for v in client.memory_stores.memory_versions.list(store.id): + print(f" {v.id}: {v.operation}") + + print("==> deleting memory store") + client.memory_stores.delete(store.id) + print("done.") + if __name__ == "__main__": TestAgentStudio.test_quickstart() + # Memory flow creates and deletes a real store; opt in explicitly: + # TestAgentStudio.test_memory_stores() diff --git a/tests/unit/test_agentstudio_memory_stores.py b/tests/unit/test_agentstudio_memory_stores.py new file mode 100644 index 00000000..b830c2a8 --- /dev/null +++ b/tests/unit/test_agentstudio_memory_stores.py @@ -0,0 +1,513 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Memory store resource shape tests. + +A client with a recording transport that routes (method, path) to +canned payloads; the memories list endpoint serves a two-page queue +so auto-pagination is exercised. +""" + +from __future__ import annotations + +from collections import deque +from typing import Any, Callable, Deque, Dict, List, Tuple + +import pytest + +from dashscope.agentstudio import ( + AsyncClient, + Client, + ConflictError, +) +from dashscope.agentstudio.exceptions import from_response +from dashscope.agentstudio.pagination import CursorPage, build_page +from dashscope.agentstudio.transport import APIResponse +from dashscope.agentstudio.types import Memory, MemoryStore, MemoryVersion + + +def _store_payload() -> Dict[str, Any]: + return { + "id": "memstore_01Hx", + "type": "memory_store", + "name": "User Preferences", + "description": "Per-user preferences.", + "metadata": {"owner": "demo"}, + "created_at": "2026-10-01T00:00:00Z", + "updated_at": "2026-10-01T00:00:00Z", + "archived_at": None, + } + + +def _archived_store_payload() -> Dict[str, Any]: + return {**_store_payload(), "archived_at": "2026-10-03T00:00:00Z"} + + +def _memory_payload() -> Dict[str, Any]: + return { + "id": "mem_01Hy", + "type": "memory", + "memory_store_id": "memstore_01Hx", + "path": "/formatting.md", + "content": "Use GAAP formatting.", + "content_sha256": "abc123", + "content_size_bytes": 21, + "memory_version_id": "memver_01Hz", + "created_at": "2026-10-01T00:00:00Z", + "updated_at": "2026-10-02T00:00:00Z", + } + + +def _version_payload() -> Dict[str, Any]: + return { + "id": "memver_01Hz", + "type": "memory_version", + "memory_store_id": "memstore_01Hx", + "memory_id": "mem_01Hy", + "operation": "modified", + "path": "/formatting.md", + "content": "Use GAAP formatting.", + "content_sha256": "abc123", + "content_size_bytes": 21, + "created_at": "2026-10-02T00:00:00Z", + "redacted_at": None, + } + + +def _redacted_version_payload() -> Dict[str, Any]: + return { + **_version_payload(), + "path": None, + "content": None, + "content_sha256": None, + "content_size_bytes": None, + "redacted_at": "2026-10-04T00:00:00Z", + } + + +_EMPTY_PAGE: Dict[str, Any] = {"data": [], "next_page": None} + +_REDACT_PATH = ( + "/memory_stores/memstore_01Hx/memory_versions/memver_01Hz/redact" +) + +_RESPONSES: Dict[Tuple[str, str], Callable[[], Any]] = { + ("POST", "/memory_stores"): _store_payload, + ("GET", "/memory_stores"): lambda: { + "data": [_store_payload()], + "next_page": None, + }, + ("GET", "/memory_stores/memstore_01Hx"): _store_payload, + ("POST", "/memory_stores/memstore_01Hx"): _store_payload, + ("DELETE", "/memory_stores/memstore_01Hx"): lambda: { + "id": "memstore_01Hx", + "type": "memory_store_deleted", + }, + ("POST", "/memory_stores/memstore_01Hx/archive"): ( + _archived_store_payload + ), + ("POST", "/memory_stores/memstore_01Hx/memories"): _memory_payload, + ("GET", "/memory_stores/memstore_01Hx/memories/mem_01Hy"): ( + _memory_payload + ), + ("POST", "/memory_stores/memstore_01Hx/memories/mem_01Hy"): ( + _memory_payload + ), + ("DELETE", "/memory_stores/memstore_01Hx/memories/mem_01Hy"): ( + lambda: {"id": "mem_01Hy", "type": "memory_deleted"} + ), + ("GET", "/memory_stores/memstore_01Hx/memory_versions"): lambda: { + "data": [_version_payload()], + "next_page": None, + }, + ("GET", "/memory_stores/memstore_01Hx/memory_versions/memver_01Hz"): ( + _version_payload + ), + ("POST", _REDACT_PATH): _redacted_version_payload, +} + + +class _Tx: + """Recording transport routing (method, path) to canned payloads. + + The memories list serves a two-page queue: the first call returns + a page with a ``next_page`` cursor, the second the (empty) last + page — exercising auto-pagination. + """ + + def __init__(self) -> None: + self.calls: List[Dict[str, Any]] = [] + self._pages: Deque[Dict[str, Any]] = deque( + [ + { + "data": [_memory_payload()], + "prefixes": ["/notes/"], + "next_page": "tok_2", + }, + {"data": [], "prefixes": [], "next_page": None}, + ], + ) + + def request(self, method: str, path: str, **kwargs: Any): + self.calls.append({"method": method, "path": path, **kwargs}) + if method == "GET" and path == ( + "/memory_stores/memstore_01Hx/memories" + ): + data = self._pages.popleft() if self._pages else dict(_EMPTY_PAGE) + return APIResponse(data=data, request_id="req_test") + factory = _RESPONSES.get((method, path)) + data = factory() if factory is not None else {} + return APIResponse(data=data, request_id="req_test") + + +class _AsyncTx: + """Async recording transport delegating to the sync routing.""" + + def __init__(self) -> None: + self._tx = _Tx() + self.calls = self._tx.calls + + async def request(self, method: str, path: str, **kwargs: Any): + return self._tx.request(method, path, **kwargs) + + +@pytest.fixture(name="client") +def _client_fixture(): + c = Client(api_key="test-key", base_url="http://test") + c.transport = _Tx() + return c + + +@pytest.fixture(name="async_client") +def _async_client_fixture(): + c = AsyncClient(api_key="test-key", base_url="http://test") + c.transport = _AsyncTx() + return c + + +# --------------------------------------------------------------------------- +# Stores +# --------------------------------------------------------------------------- + + +def test_store_create_posts_body(client): + store = client.memory_stores.create( + name="User Preferences", + description="Per-user preferences.", + metadata={"owner": "demo"}, + ) + call = client.transport.calls[0] + assert call["method"] == "POST" + assert call["path"] == "/memory_stores" + assert call["json"] == { + "name": "User Preferences", + "description": "Per-user preferences.", + "metadata": {"owner": "demo"}, + } + assert isinstance(store, MemoryStore) + assert store.id == "memstore_01Hx" + assert store.type == "memory_store" + + +def test_store_list_serializes_bool_query(client): + page = client.memory_stores.list(include_archived=True, limit=10) + call = client.transport.calls[0] + assert call["method"] == "GET" + assert call["params"]["include_archived"] == "true" + assert call["params"]["limit"] == 10 + assert len(page.data) == 1 + assert isinstance(page.data[0], MemoryStore) + + +def test_store_retrieve_and_get_alias(client): + store = client.memory_stores.retrieve("memstore_01Hx") + assert client.memory_stores.get("memstore_01Hx") == store + assert client.transport.calls[0]["path"] == ( + "/memory_stores/memstore_01Hx" + ) + + +def test_store_update_posts_partial_body(client): + client.memory_stores.update("memstore_01Hx", name="Renamed") + assert client.transport.calls[0]["json"] == {"name": "Renamed"} + + +def test_store_update_requires_at_least_one_field(client): + with pytest.raises(TypeError): + client.memory_stores.update("memstore_01Hx") + + +def test_store_archive_returns_archived_store(client): + store = client.memory_stores.archive("memstore_01Hx") + assert client.transport.calls[0]["path"] == ( + "/memory_stores/memstore_01Hx/archive" + ) + assert store.archived_at is not None + + +def test_store_delete_returns_deleted_marker(client): + resp = client.memory_stores.delete("memstore_01Hx") + assert client.transport.calls[0]["method"] == "DELETE" + assert resp.id == "memstore_01Hx" + assert resp.type == "memory_store_deleted" + + +# --------------------------------------------------------------------------- +# Memories +# --------------------------------------------------------------------------- + + +def test_memory_create_posts_path_and_content(client): + mem = client.memory_stores.memories.create( + "memstore_01Hx", + path="/formatting.md", + content="Use GAAP formatting.", + ) + call = client.transport.calls[0] + assert call["method"] == "POST" + assert call["path"] == "/memory_stores/memstore_01Hx/memories" + assert call["json"] == { + "path": "/formatting.md", + "content": "Use GAAP formatting.", + } + assert isinstance(mem, Memory) + assert mem.memory_store_id == "memstore_01Hx" + + +def test_memory_list_returns_prefixes_and_pages(client): + page = client.memory_stores.memories.list( + "memstore_01Hx", + path_prefix="/", + depth=1, + view="basic", + ) + call = client.transport.calls[0] + assert call["params"] == { + "path_prefix": "/", + "depth": 1, + "view": "basic", + } + assert isinstance(page, CursorPage) + assert page.prefixes == ["/notes/"] + assert len(page.data) == 1 + assert isinstance(page.data[0], Memory) + + # Iterating auto-paginates through the queued second page. + assert len(list(page)) == 1 + + +def test_memory_list_depth_one_passes_through_wire(client): + client.memory_stores.memories.list("memstore_01Hx", depth=1) + assert client.transport.calls[0]["params"] == {"depth": 1} + + +def test_memory_list_depth_zero_passes_through_wire(client): + # depth=0 is the native flat mode (same as omitting it). + client.memory_stores.memories.list("memstore_01Hx", depth=0) + assert client.transport.calls[0]["params"] == {"depth": 0} + + +_FLAT_LISTING = { + "data": [ + {"id": "mem_1", "type": "memory", "path": "/root.md"}, + {"id": "mem_2", "type": "memory", "path": "/notes/top.md"}, + {"id": "mem_3", "type": "memory", "path": "/notes/deep/file.md"}, + ], + "next_page": None, +} + + +class _FlatTx: + """Transport returning a flat (no-depth) memories listing.""" + + def __init__(self): + self.calls: List[Dict[str, Any]] = [] + + def request(self, method, path, **kwargs): + self.calls.append({"method": method, "path": path, **kwargs}) + return APIResponse(data=dict(_FLAT_LISTING), request_id="req_d") + + +def _flat_client() -> Client: + c = Client(api_key="test-key", base_url="http://test") + c.transport = _FlatTx() + return c + + +def test_memory_list_depth_two_emulated_client_side(): + # The backend rejects wire depth >= 2; the SDK omits it and derives + # the bounded view locally. + c = _flat_client() + page = c.memory_stores.memories.list("memstore_01Hx", depth=2) + assert "depth" not in c.transport.calls[0]["params"] + # The 3-segment file is cut but leaves its frontier directory. + assert [m.path for m in page.data] == ["/root.md", "/notes/top.md"] + assert page.prefixes == ["/notes/", "/notes/deep/"] + + +def test_memory_list_depth_three_emulated(): + c = _flat_client() + page = c.memory_stores.memories.list("memstore_01Hx", depth=3) + assert "depth" not in c.transport.calls[0]["params"] + assert [m.path for m in page.data] == [ + "/root.md", + "/notes/top.md", + "/notes/deep/file.md", + ] + assert page.extra["prefixes"] == ["/notes/", "/notes/deep/"] + + +def test_page_extra_bag_preserves_unknown_envelope_fields(): + payload = { + "data": [{"id": "memstore_1", "type": "memory_store"}], + "next_page": None, + "prefixes": ["/a/"], + "future_field": {"x": 1}, + } + page = build_page( + payload=payload, + item_factory=lambda p: MemoryStore(**p), + request_id="req_x", + ) + assert isinstance(page.data[0], MemoryStore) + assert page.extra == { + "prefixes": ["/a/"], + "future_field": {"x": 1}, + } + + +def test_store_list_page_extra_defaults_empty(client): + page = client.memory_stores.list() + assert page.extra == {} + # Non-memory pages: the typed accessor returns an empty list. + assert page.prefixes == [] + + +def test_memory_update_sends_flattened_precondition(client): + mem = client.memory_stores.memories.update( + "memstore_01Hx", + "mem_01Hy", + content="CORRECTED", + content_sha256="abc123", + ) + assert client.transport.calls[0]["json"] == { + "content": "CORRECTED", + "content_sha256": "abc123", + } + assert isinstance(mem, Memory) + + +def test_memory_update_requires_path_or_content(client): + with pytest.raises(TypeError): + client.memory_stores.memories.update( + "memstore_01Hx", + "mem_01Hy", + content_sha256="abc123", + ) + + +def test_memory_delete_returns_marker(client): + resp = client.memory_stores.memories.delete( + "memstore_01Hx", + "mem_01Hy", + ) + assert client.transport.calls[0]["method"] == "DELETE" + assert resp.type == "memory_deleted" + + +# --------------------------------------------------------------------------- +# Memory versions +# --------------------------------------------------------------------------- + + +def test_version_list_uses_dotted_time_query_keys(client): + page = client.memory_stores.memory_versions.list( + "memstore_01Hx", + memory_id="mem_01Hy", + operation="modified", + created_at_gte="2026-10-01T00:00:00Z", + created_at_lte="2026-10-31T00:00:00Z", + view="full", + ) + call = client.transport.calls[0] + assert call["path"] == "/memory_stores/memstore_01Hx/memory_versions" + assert call["params"] == { + "memory_id": "mem_01Hy", + "operation": "modified", + "created_at.gte": "2026-10-01T00:00:00Z", + "created_at.lte": "2026-10-31T00:00:00Z", + "view": "full", + } + assert isinstance(page.data[0], MemoryVersion) + assert page.data[0].operation == "modified" + + +def test_version_retrieve_and_redact(client): + version = client.memory_stores.memory_versions.retrieve( + "memstore_01Hx", + "memver_01Hz", + ) + assert version.id == "memver_01Hz" + + redacted = client.memory_stores.memory_versions.redact( + "memstore_01Hx", + "memver_01Hz", + ) + call = client.transport.calls[1] + assert call["method"] == "POST" + assert call["path"] == ( + "/memory_stores/memstore_01Hx/memory_versions/memver_01Hz/redact" + ) + assert redacted.content is None + assert redacted.redacted_at == "2026-10-04T00:00:00Z" + + +# --------------------------------------------------------------------------- +# Error mapping +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "code", + [ + "memory_path_conflict_error", + "memory_precondition_failed_error", + "memory_store_archived_error", + ], +) +def test_memory_error_codes_raise_conflict_with_code(code: str) -> None: + err = from_response( + status_code=409, + body={"error": {"code": code, "message": "boom"}}, + ) + assert type(err) is ConflictError + assert err.code == code + assert err.status_code == 409 + + +# --------------------------------------------------------------------------- +# Async variants +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_memory_resources(async_client): + store = await async_client.memory_stores.create(name="Prefs") + assert isinstance(store, MemoryStore) + + page = await async_client.memory_stores.memories.list( + "memstore_01Hx", + ) + assert page.prefixes == ["/notes/"] + + mem = await async_client.memory_stores.memories.create( + "memstore_01Hx", + path="/formatting.md", + content="Use GAAP formatting.", + ) + assert isinstance(mem, Memory) + + version = await async_client.memory_stores.memory_versions.redact( + "memstore_01Hx", + "memver_01Hz", + ) + assert version.redacted_at is not None