From c3a3cd4ad8ce3981e593a79c4a878ec797260745 Mon Sep 17 00:00:00 2001 From: foleydang Date: Fri, 16 Oct 2026 16:56:53 +0800 Subject: [PATCH] feat(agentstudio): memory stores, memories and memory versions Add the memory resource family mirroring the Claude Managed Agents client.beta.memoryStores() surface (bailian-app-control MemoryStoreController API): - MemoryStores CRUD + archive, nested Memories (create/list/retrieve/ update/delete with optimistic lock) and MemoryVersions (list/retrieve/redact), sync + CompletableFuture - MemoryStore/Memory/MemoryVersion models; params with dotted wire keys (created_at.gte); ApiException carries the memory 409 codes - CursorPage gains a typed prefixes field (memory listings only); depth >= 2 is derived client-side from the flat listing (the backend only implements depth 0/1) - Sample, fixtures and 13 MockWebServer tests (request shapes, two-page auto-pagination, depth emulation across pages, nested 409 error body parsing, empty-metadata clear) Co-Authored-By: Claude Code --- samples/AgentStudioMemoryStores.java | 94 +++ .../agentstudio/AgentStudioClient.java | 7 + .../dashscope/agentstudio/model/Memory.java | 49 ++ .../agentstudio/model/MemoryStore.java | 45 ++ .../agentstudio/model/MemoryVersion.java | 51 ++ .../agentstudio/pagination/CursorPage.java | 4 + .../agentstudio/param/MemoryCreateParam.java | 35 ++ .../agentstudio/param/MemoryListParam.java | 48 ++ .../param/MemoryStoreCreateParam.java | 42 ++ .../param/MemoryStoreListParam.java | 37 ++ .../param/MemoryStoreUpdateParam.java | 42 ++ .../agentstudio/param/MemoryUpdateParam.java | 41 ++ .../param/MemoryVersionListParam.java | 48 ++ .../agentstudio/resource/MemoryStores.java | 536 ++++++++++++++++++ .../TestAgentStudioMemoryStores.java | 361 ++++++++++++ src/test/resources/agentstudio.json | 289 +++++++++- 16 files changed, 1700 insertions(+), 29 deletions(-) create mode 100644 samples/AgentStudioMemoryStores.java create mode 100644 src/main/java/com/alibaba/dashscope/agentstudio/model/Memory.java create mode 100644 src/main/java/com/alibaba/dashscope/agentstudio/model/MemoryStore.java create mode 100644 src/main/java/com/alibaba/dashscope/agentstudio/model/MemoryVersion.java create mode 100644 src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryCreateParam.java create mode 100644 src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryListParam.java create mode 100644 src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreCreateParam.java create mode 100644 src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreListParam.java create mode 100644 src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreUpdateParam.java create mode 100644 src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryUpdateParam.java create mode 100644 src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryVersionListParam.java create mode 100644 src/main/java/com/alibaba/dashscope/agentstudio/resource/MemoryStores.java create mode 100644 src/test/java/com/alibaba/dashscope/TestAgentStudioMemoryStores.java diff --git a/samples/AgentStudioMemoryStores.java b/samples/AgentStudioMemoryStores.java new file mode 100644 index 00000000..caf2ea63 --- /dev/null +++ b/samples/AgentStudioMemoryStores.java @@ -0,0 +1,94 @@ +import com.alibaba.dashscope.agentstudio.AgentStudioClient; +import com.alibaba.dashscope.agentstudio.model.AgentStudioDeletionStatus; +import com.alibaba.dashscope.agentstudio.model.Memory; +import com.alibaba.dashscope.agentstudio.model.MemoryStore; +import com.alibaba.dashscope.agentstudio.model.MemoryVersion; +import com.alibaba.dashscope.agentstudio.param.MemoryCreateParam; +import com.alibaba.dashscope.agentstudio.param.MemoryListParam; +import com.alibaba.dashscope.agentstudio.param.MemoryStoreCreateParam; +import com.alibaba.dashscope.agentstudio.param.MemoryStoreListParam; +import com.alibaba.dashscope.agentstudio.param.MemoryUpdateParam; +import com.alibaba.dashscope.agentstudio.param.MemoryVersionListParam; + +/** + * Memory store example: seed a store, read it back, audit versions, cleanup. + * + *

Set DASHSCOPE_API_KEY and DASHSCOPE_WORKSPACE before running. + */ +public class AgentStudioMemoryStores { + + public static void main(String[] args) { + try (AgentStudioClient client = new AgentStudioClient()) { + // 1. Create a store; description is read by the agent - write it for + // the model, not for humans. + MemoryStore store = + client + .memoryStores() + .create( + MemoryStoreCreateParam.builder() + .name("demo-preferences") + .description("Per-user preferences and project context.") + .build()); + System.out.printf("store: %s%n", store.getId()); + + // 2. Seed reference material before any session runs. + Memory memory = + client + .memoryStores() + .memories() + .create( + store.getId(), + MemoryCreateParam.builder() + .path("/formatting_standards.md") + .content("All reports use GAAP formatting.") + .build()); + System.out.printf( + "memory: %s %s (%d bytes)%n", + memory.getId(), memory.getPath(), memory.getContentSizeBytes()); + + // 3. Read back with content (view=full; the default basic is metadata + // only). Print prefixes before iterating: the loop auto-paginates + // and prefixes are per page. + com.alibaba.dashscope.agentstudio.pagination.CursorPage page = + client + .memoryStores() + .memories() + .list(store.getId(), MemoryListParam.builder().view("full").build()); + System.out.printf("prefixes: %s%n", page.getPrefixes()); + for (Memory item : page) { + System.out.printf("%s -> %s%n", item.getPath(), item.getContent()); + } + + // 4. Update with the optimistic lock (contentSha256 from step 2). + Memory updated = + client + .memoryStores() + .memories() + .update( + store.getId(), + memory.getId(), + MemoryUpdateParam.builder() + .content("All reports use GAAP formatting. ISO-8601 dates.") + .contentSha256(memory.getContentSha256()) + .build()); + System.out.printf("updated sha: %s%n", updated.getContentSha256()); + + // 5. Audit history: every mutation produced a memver_ snapshot. + for (MemoryVersion version : + client + .memoryStores() + .memoryVersions() + .list(store.getId(), MemoryVersionListParam.builder().build())) { + System.out.printf("version: %s %s%n", version.getId(), version.getOperation()); + } + + // 6. List stores and clean up. + for (MemoryStore item : + client.memoryStores().list(MemoryStoreListParam.builder().limit(20).build())) { + System.out.printf("store: %s %s%n", item.getId(), item.getName()); + } + AgentStudioDeletionStatus deleted = client.memoryStores().delete(store.getId()); + System.out.printf("deleted: %s%n", deleted.getType()); + } + } +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/AgentStudioClient.java b/src/main/java/com/alibaba/dashscope/agentstudio/AgentStudioClient.java index 89e72ab5..2a1f84dc 100644 --- a/src/main/java/com/alibaba/dashscope/agentstudio/AgentStudioClient.java +++ b/src/main/java/com/alibaba/dashscope/agentstudio/AgentStudioClient.java @@ -6,6 +6,7 @@ import com.alibaba.dashscope.agentstudio.resource.Deployments; import com.alibaba.dashscope.agentstudio.resource.Environments; import com.alibaba.dashscope.agentstudio.resource.Files; +import com.alibaba.dashscope.agentstudio.resource.MemoryStores; import com.alibaba.dashscope.agentstudio.resource.Security; import com.alibaba.dashscope.agentstudio.resource.Sessions; import com.alibaba.dashscope.agentstudio.resource.Skills; @@ -24,6 +25,7 @@ public class AgentStudioClient implements Closeable { private final Environments environments; private final Skills skills; private final Vaults vaults; + private final MemoryStores memoryStores; private final Files files; private final WebhookEndpoints webhookEndpoints; private final Security security; @@ -56,6 +58,7 @@ public AgentStudioClient( this.files = new Files(this.baseUrl, connectionOptions, apiKey); this.skills = new Skills(this.baseUrl, connectionOptions, apiKey, this.files); this.vaults = new Vaults(this.baseUrl, connectionOptions, apiKey); + this.memoryStores = new MemoryStores(this.baseUrl, connectionOptions, apiKey); this.webhookEndpoints = new WebhookEndpoints(this.baseUrl, connectionOptions, apiKey); this.security = new Security(this.baseUrl, connectionOptions, apiKey); } @@ -129,6 +132,10 @@ public Vaults vaults() { return vaults; } + public MemoryStores memoryStores() { + return memoryStores; + } + public Files files() { return files; } diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/model/Memory.java b/src/main/java/com/alibaba/dashscope/agentstudio/model/Memory.java new file mode 100644 index 00000000..f5dd32db --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/model/Memory.java @@ -0,0 +1,49 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.model; + +import com.alibaba.dashscope.common.FlattenResultBase; +import com.google.gson.annotations.SerializedName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * One text document inside a memory store, addressed by {@code path}. + * + *

{@code content} appears only in retrieve responses and in list responses with {@code + * view="full"}; create and update never return it. {@code contentSha256} identifies the content + * revision — pass it back to {@code memories().update()} for optimistic concurrency (a mismatch + * returns 409 {@code memory_precondition_failed_error}). + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class Memory extends FlattenResultBase { + @SerializedName("id") + private String id; + + @SerializedName("type") + private String type; + + @SerializedName("memory_store_id") + private String memoryStoreId; + + @SerializedName("path") + private String path; + + @SerializedName("content") + private String content; + + @SerializedName("content_sha256") + private String contentSha256; + + @SerializedName("content_size_bytes") + private Long contentSizeBytes; + + @SerializedName("memory_version_id") + private String memoryVersionId; + + @SerializedName("created_at") + private String createdAt; + + @SerializedName("updated_at") + private String updatedAt; +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/model/MemoryStore.java b/src/main/java/com/alibaba/dashscope/agentstudio/model/MemoryStore.java new file mode 100644 index 00000000..195737af --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/model/MemoryStore.java @@ -0,0 +1,45 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.model; + +import com.alibaba.dashscope.common.FlattenResultBase; +import com.google.gson.annotations.SerializedName; +import java.util.Map; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * A workspace-scoped memory store: a collection of small text documents that persists across + * sessions. + * + *

Stores are attached to sessions via the session {@code resources[]} array and mounted into the + * session container at {@code /mnt/memory//}. {@code archivedAt} is set once the store is + * archived: read-only afterwards, existing session attachments keep working, but new sessions + * cannot reference it. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class MemoryStore extends FlattenResultBase { + @SerializedName("id") + private String id; + + @SerializedName("type") + private String type; + + @SerializedName("name") + private String name; + + @SerializedName("description") + private String description; + + @SerializedName("metadata") + private Map metadata; + + @SerializedName("archived_at") + private String archivedAt; + + @SerializedName("created_at") + private String createdAt; + + @SerializedName("updated_at") + private String updatedAt; +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/model/MemoryVersion.java b/src/main/java/com/alibaba/dashscope/agentstudio/model/MemoryVersion.java new file mode 100644 index 00000000..f9df13d3 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/model/MemoryVersion.java @@ -0,0 +1,51 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.model; + +import com.alibaba.dashscope.common.FlattenResultBase; +import com.google.gson.annotations.SerializedName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Immutable snapshot produced by every memory mutation; versions are returned newest-first. + * + *

{@code operation} is {@code created}, {@code modified} or {@code deleted}. After {@code + * redact()}, {@code path}, {@code content}, {@code contentSha256} and {@code contentSizeBytes} are + * cleared; the audit trail is preserved. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class MemoryVersion extends FlattenResultBase { + @SerializedName("id") + private String id; + + @SerializedName("type") + private String type; + + @SerializedName("memory_store_id") + private String memoryStoreId; + + @SerializedName("memory_id") + private String memoryId; + + @SerializedName("operation") + private String operation; + + @SerializedName("path") + private String path; + + @SerializedName("content") + private String content; + + @SerializedName("content_sha256") + private String contentSha256; + + @SerializedName("content_size_bytes") + private Long contentSizeBytes; + + @SerializedName("created_at") + private String createdAt; + + @SerializedName("redacted_at") + private String redactedAt; +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/pagination/CursorPage.java b/src/main/java/com/alibaba/dashscope/agentstudio/pagination/CursorPage.java index 4e875ad7..818e6c75 100644 --- a/src/main/java/com/alibaba/dashscope/agentstudio/pagination/CursorPage.java +++ b/src/main/java/com/alibaba/dashscope/agentstudio/pagination/CursorPage.java @@ -20,6 +20,10 @@ public class CursorPage extends FlattenResultBase implements Iterable { @SerializedName("next_page") private String nextPage; + /** Directory prefixes of a tree-style listing (memory listings only, null elsewhere). */ + @SerializedName("prefixes") + private List prefixes; + @EqualsAndHashCode.Exclude private transient Function>> fetchNext; diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryCreateParam.java b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryCreateParam.java new file mode 100644 index 00000000..6b02f222 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryCreateParam.java @@ -0,0 +1,35 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.param; + +import com.alibaba.dashscope.base.FlattenHalfDuplexParamBase; +import com.google.gson.JsonObject; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.experimental.SuperBuilder; + +/** + * Request body for {@code POST /memory_stores/{store_id}/memories}. + * + *

{@code path} rules (enforced server-side, case-sensitive): POSIX style with a leading {@code + * /} and no trailing {@code /}; 2..1024 UTF-8 bytes; NFC-normalized; every segment non-empty and + * not {@code .} or {@code ..}; no control / format / line- or paragraph-separator code points. + * {@code content} is UTF-8 text, at most 102400 bytes. A memory already existing at {@code path} + * returns 409 {@code memory_path_conflict_error}. + */ +@Data +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class MemoryCreateParam extends FlattenHalfDuplexParamBase { + @NonNull private String path; + @NonNull private String content; + + @Override + public JsonObject getHttpBody() { + JsonObject body = new JsonObject(); + body.addProperty("path", path); + body.addProperty("content", content); + addExtraBody(body); + return body; + } +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryListParam.java b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryListParam.java new file mode 100644 index 00000000..e5fc6fb2 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryListParam.java @@ -0,0 +1,48 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.param; + +import com.alibaba.dashscope.agentstudio.AgentStudioConstants; +import com.alibaba.dashscope.base.FlattenHalfDuplexParamBase; +import com.google.gson.JsonObject; +import lombok.Builder.Default; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; + +/** + * Query params for {@code GET /memory_stores/{store_id}/memories}. + * + *

{@code pathPrefix} scopes the listing (server-validated: must start and end with {@code /} — + * {@code "/notes/"} matches {@code /notes/a.md} but not {@code /notes_backup/old.md}; a bare {@code + * "/"} lists the whole store). {@code view} is {@code basic} (metadata only, the default; {@code + * limit} up to 100) or {@code full} (include {@code content}; {@code limit} is capped at 20 and + * higher values are rejected with 400 — CMA silently clamps instead). {@code depth} is 0/absent + * (flat) or 1 (single-level tree) on the wire; larger values are derived client-side from a flat + * listing (no {@code depth} is sent). With {@code view=full}, the wire still transfers the content + * of files the derivation cuts — prefer {@code basic} for deep walks. + */ +@Data +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class MemoryListParam extends FlattenHalfDuplexParamBase { + @Default private String pathPrefix = null; + @Default private Integer depth = null; + @Default private String view = null; + @Default private Integer limit = null; + @Default private String page = null; + + @Override + public JsonObject getHttpBody() { + return new JsonObject(); + } + + public String toQueryString() { + StringBuilder sb = new StringBuilder(); + AgentStudioConstants.appendParam(sb, "path_prefix", pathPrefix); + AgentStudioConstants.appendParam(sb, "depth", depth); + AgentStudioConstants.appendParam(sb, "view", view); + AgentStudioConstants.appendParam(sb, "limit", limit); + AgentStudioConstants.appendParam(sb, "page", page); + return sb.toString(); + } +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreCreateParam.java b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreCreateParam.java new file mode 100644 index 00000000..7f01eb07 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreCreateParam.java @@ -0,0 +1,42 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.param; + +import com.alibaba.dashscope.base.FlattenHalfDuplexParamBase; +import com.alibaba.dashscope.utils.JsonUtils; +import com.google.gson.JsonObject; +import java.util.Map; +import lombok.Builder.Default; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.experimental.SuperBuilder; + +/** + * Request body for {@code POST /memory_stores}. + * + *

{@code name} must contain 1..255 characters; names may repeat within a workspace, and the + * session mount slug {@code /mnt/memory//} is derived from it. {@code description} is passed + * to the agent — write it for the model, not for humans. + */ +@Data +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class MemoryStoreCreateParam extends FlattenHalfDuplexParamBase { + @NonNull private String name; + @Default private String description = null; + @Default private Map metadata = null; + + @Override + public JsonObject getHttpBody() { + JsonObject body = new JsonObject(); + body.addProperty("name", name); + if (description != null) { + body.addProperty("description", description); + } + if (metadata != null) { + body.add("metadata", JsonUtils.toJsonElement(metadata)); + } + addExtraBody(body); + return body; + } +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreListParam.java b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreListParam.java new file mode 100644 index 00000000..3c670d53 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreListParam.java @@ -0,0 +1,37 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.param; + +import com.alibaba.dashscope.agentstudio.AgentStudioConstants; +import com.alibaba.dashscope.base.FlattenHalfDuplexParamBase; +import com.google.gson.JsonObject; +import lombok.Builder.Default; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; + +/** + * Query params for {@code GET /memory_stores}. + * + *

The public API has no {@code keyword} filter (that is a console-only capability). + */ +@Data +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class MemoryStoreListParam extends FlattenHalfDuplexParamBase { + @Default private Integer limit = null; + @Default private String page = null; + @Default private Boolean includeArchived = null; + + @Override + public JsonObject getHttpBody() { + return new JsonObject(); + } + + public String toQueryString() { + StringBuilder sb = new StringBuilder(); + AgentStudioConstants.appendParam(sb, "limit", limit); + AgentStudioConstants.appendParam(sb, "page", page); + AgentStudioConstants.appendParam(sb, "include_archived", includeArchived); + return sb.toString(); + } +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreUpdateParam.java b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreUpdateParam.java new file mode 100644 index 00000000..71fe4795 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryStoreUpdateParam.java @@ -0,0 +1,42 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.param; + +import com.alibaba.dashscope.base.FlattenHalfDuplexParamBase; +import com.alibaba.dashscope.utils.JsonUtils; +import com.google.gson.JsonObject; +import java.util.Map; +import lombok.Builder.Default; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; + +/** + * Request body for {@code POST /memory_stores/{store_id}}. + * + *

{@code metadata} is replaced wholesale (unlike CMA's merge-patch): keys not included here are + * dropped; a key set to {@code null} deletes it. To change one key, re-send the whole mapping. + */ +@Data +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class MemoryStoreUpdateParam extends FlattenHalfDuplexParamBase { + @Default private String name = null; + @Default private String description = null; + @Default private Map metadata = null; + + @Override + public JsonObject getHttpBody() { + JsonObject body = new JsonObject(); + if (name != null) { + body.addProperty("name", name); + } + if (description != null) { + body.addProperty("description", description); + } + if (metadata != null) { + body.add("metadata", JsonUtils.toJsonElement(metadata)); + } + addExtraBody(body); + return body; + } +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryUpdateParam.java b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryUpdateParam.java new file mode 100644 index 00000000..1967f8cc --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryUpdateParam.java @@ -0,0 +1,41 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.param; + +import com.alibaba.dashscope.base.FlattenHalfDuplexParamBase; +import com.google.gson.JsonObject; +import lombok.Builder.Default; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; + +/** + * Request body for {@code POST /memory_stores/{store_id}/memories/{memory_id}}. + * + *

Mutate {@code content}, rename via {@code path}, or both (at least one is required + * server-side). Passing the current {@code contentSha256} (from the retrieved memory) enables + * optimistic concurrency — a mismatch returns 409 {@code memory_precondition_failed_error}. + */ +@Data +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class MemoryUpdateParam extends FlattenHalfDuplexParamBase { + @Default private String path = null; + @Default private String content = null; + @Default private String contentSha256 = null; + + @Override + public JsonObject getHttpBody() { + JsonObject body = new JsonObject(); + if (path != null) { + body.addProperty("path", path); + } + if (content != null) { + body.addProperty("content", content); + } + if (contentSha256 != null) { + body.addProperty("content_sha256", contentSha256); + } + addExtraBody(body); + return body; + } +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryVersionListParam.java b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryVersionListParam.java new file mode 100644 index 00000000..67aecf52 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/param/MemoryVersionListParam.java @@ -0,0 +1,48 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.param; + +import com.alibaba.dashscope.agentstudio.AgentStudioConstants; +import com.alibaba.dashscope.base.FlattenHalfDuplexParamBase; +import com.google.gson.JsonObject; +import lombok.Builder.Default; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; + +/** + * Query params for {@code GET /memory_stores/{store_id}/memory_versions}. + * + *

Filters: {@code memoryId}, {@code operation} ({@code created} / {@code modified} / {@code + * deleted}), {@code createdAtGte} / {@code createdAtLte} (ISO-8601 timestamps; sent on the wire as + * {@code created_at.gte} / {@code created_at.lte}), and {@code view} ({@code basic} / {@code + * full}). Versions are returned newest-first. + */ +@Data +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class MemoryVersionListParam extends FlattenHalfDuplexParamBase { + @Default private String memoryId = null; + @Default private String operation = null; + @Default private String createdAtGte = null; + @Default private String createdAtLte = null; + @Default private String view = null; + @Default private Integer limit = null; + @Default private String page = null; + + @Override + public JsonObject getHttpBody() { + return new JsonObject(); + } + + public String toQueryString() { + StringBuilder sb = new StringBuilder(); + AgentStudioConstants.appendParam(sb, "memory_id", memoryId); + AgentStudioConstants.appendParam(sb, "operation", operation); + AgentStudioConstants.appendParam(sb, "created_at.gte", createdAtGte); + AgentStudioConstants.appendParam(sb, "created_at.lte", createdAtLte); + AgentStudioConstants.appendParam(sb, "view", view); + AgentStudioConstants.appendParam(sb, "limit", limit); + AgentStudioConstants.appendParam(sb, "page", page); + return sb.toString(); + } +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/resource/MemoryStores.java b/src/main/java/com/alibaba/dashscope/agentstudio/resource/MemoryStores.java new file mode 100644 index 00000000..bf7fd838 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/resource/MemoryStores.java @@ -0,0 +1,536 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.resource; + +import com.alibaba.dashscope.agentstudio.AgentStudioConstants; +import com.alibaba.dashscope.agentstudio.model.AgentStudioDeletionStatus; +import com.alibaba.dashscope.agentstudio.model.Memory; +import com.alibaba.dashscope.agentstudio.model.MemoryStore; +import com.alibaba.dashscope.agentstudio.model.MemoryVersion; +import com.alibaba.dashscope.agentstudio.pagination.CursorPage; +import com.alibaba.dashscope.agentstudio.param.MemoryCreateParam; +import com.alibaba.dashscope.agentstudio.param.MemoryListParam; +import com.alibaba.dashscope.agentstudio.param.MemoryStoreCreateParam; +import com.alibaba.dashscope.agentstudio.param.MemoryStoreListParam; +import com.alibaba.dashscope.agentstudio.param.MemoryStoreUpdateParam; +import com.alibaba.dashscope.agentstudio.param.MemoryUpdateParam; +import com.alibaba.dashscope.agentstudio.param.MemoryVersionListParam; +import com.alibaba.dashscope.api.GeneralApi; +import com.alibaba.dashscope.base.HalfDuplexParamBase; +import com.alibaba.dashscope.common.FlattenResultBase; +import com.alibaba.dashscope.common.GeneralGetParam; +import com.alibaba.dashscope.exception.InputRequiredException; +import com.alibaba.dashscope.protocol.ConnectionOptions; +import com.alibaba.dashscope.protocol.GeneralServiceOption; +import com.alibaba.dashscope.protocol.HttpMethod; +import com.alibaba.dashscope.utils.StringUtils; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; + +/** + * Memory stores: workspace-scoped collections of small text documents that persist across sessions. + * Stores hold {@code memories} ({@code mem_...}, one text file per path) and their {@code + * memory_versions} ({@code memver_...}, immutable snapshots per mutation). + * + *

Follows the Claude Managed Agents {@code client.beta.memoryStores()} surface, with these + * verified divergences (the backend is bailian-app-control {@code MemoryStoreController} over + * bailian-app-context OTS storage): + * + *

+ */ +public final class 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. + static final int MAX_WIRE_DEPTH = 1; + + private final GeneralApi api; + private final String baseUrl; + private final String apiKey; + private final Memories memories; + private final MemoryVersions memoryVersions; + + public MemoryStores(String baseUrl, ConnectionOptions connectionOptions, String apiKey) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.api = connectionOptions != null ? new GeneralApi<>(connectionOptions) : new GeneralApi<>(); + this.memories = new Memories(); + this.memoryVersions = new MemoryVersions(); + } + + public Memories memories() { + return memories; + } + + public MemoryVersions memoryVersions() { + return memoryVersions; + } + + public MemoryStore create(MemoryStoreCreateParam param) { + return AsyncHelper.joinAndUnwrap(createAsync(param)); + } + + public MemoryStore retrieve(String storeId) { + return retrieve(storeId, null, null); + } + + public MemoryStore retrieve(String storeId, String apiKey, Map headers) { + return AsyncHelper.joinAndUnwrap(retrieveAsync(storeId, apiKey, headers)); + } + + public MemoryStore update(String storeId, MemoryStoreUpdateParam param) { + return AsyncHelper.joinAndUnwrap(updateAsync(storeId, param)); + } + + public CursorPage list(MemoryStoreListParam param) { + return AsyncHelper.joinAndUnwrap(listAsync(param)); + } + + public MemoryStore archive(String storeId) { + return AsyncHelper.joinAndUnwrap(archiveAsync(storeId)); + } + + public AgentStudioDeletionStatus delete(String storeId) { + return delete(storeId, null, null); + } + + public AgentStudioDeletionStatus delete( + String storeId, String apiKey, Map headers) { + return AsyncHelper.joinAndUnwrap(deleteAsync(storeId, apiKey, headers)); + } + + public CompletableFuture createAsync(MemoryStoreCreateParam param) { + if (param == null) { + return AsyncHelper.failedFuture(new InputRequiredException("param is required!")); + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption(HttpMethod.POST, "memory_stores", baseUrl); + return AsyncHelper.callAsync(api, AgentStudioConstants.withApiKey(apiKey, param), opt) + .thenApply(r -> FlattenResultBase.fromDashScopeResult(r, MemoryStore.class)); + } + + public CompletableFuture retrieveAsync(String storeId) { + return retrieveAsync(storeId, null, null); + } + + public CompletableFuture retrieveAsync( + String storeId, String apiKey, Map headers) { + if (storeId == null || storeId.isEmpty()) { + return AsyncHelper.failedFuture(new InputRequiredException("storeId is required!")); + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption( + HttpMethod.GET, StringUtils.format("memory_stores/%s", storeId), baseUrl); + String resolvedKey = apiKey != null ? apiKey : this.apiKey; + return AsyncHelper.callAsync( + api, + GeneralGetParam.builder() + .apiKey(resolvedKey) + .headers(headers != null ? headers : new HashMap<>()) + .build(), + opt) + .thenApply(r -> FlattenResultBase.fromDashScopeResult(r, MemoryStore.class)); + } + + public CompletableFuture updateAsync(String storeId, MemoryStoreUpdateParam param) { + if (storeId == null || storeId.isEmpty()) { + return AsyncHelper.failedFuture(new InputRequiredException("storeId is required!")); + } + if (param == null) { + return AsyncHelper.failedFuture(new InputRequiredException("param is required!")); + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption( + HttpMethod.POST, StringUtils.format("memory_stores/%s", storeId), baseUrl); + return AsyncHelper.callAsync(api, AgentStudioConstants.withApiKey(apiKey, param), opt) + .thenApply(r -> FlattenResultBase.fromDashScopeResult(r, MemoryStore.class)); + } + + public CompletableFuture> listAsync(MemoryStoreListParam param) { + if (param == null) { + return AsyncHelper.failedFuture(new InputRequiredException("param is required!")); + } + String query = param.toQueryString(); + String path = query.isEmpty() ? "memory_stores" : "memory_stores?" + query; + GeneralServiceOption opt = AgentStudioConstants.newServiceOption(HttpMethod.GET, path, baseUrl); + return AsyncHelper.callAsync( + api, GeneralGetParam.builder().apiKey(apiKey).headers(new HashMap<>()).build(), opt) + .thenApply( + r -> { + Type type = new TypeToken>() {}.getType(); + CursorPage page = FlattenResultBase.fromDashScopeResult(r, type); + page.setFetchNext( + cursor -> + listAsync( + MemoryStoreListParam.builder() + .limit(param.getLimit()) + .includeArchived(param.getIncludeArchived()) + .page(cursor) + .build())); + return page; + }); + } + + public CompletableFuture archiveAsync(String storeId) { + if (storeId == null || storeId.isEmpty()) { + return AsyncHelper.failedFuture(new InputRequiredException("storeId is required!")); + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption( + HttpMethod.POST, StringUtils.format("memory_stores/%s/archive", storeId), baseUrl); + return AsyncHelper.callAsync( + api, + AgentStudioConstants.withApiKey(apiKey, MemoryStoreUpdateParam.builder().build()), + opt) + .thenApply(r -> FlattenResultBase.fromDashScopeResult(r, MemoryStore.class)); + } + + public CompletableFuture deleteAsync(String storeId) { + return deleteAsync(storeId, null, null); + } + + public CompletableFuture deleteAsync( + String storeId, String apiKey, Map headers) { + if (storeId == null || storeId.isEmpty()) { + return AsyncHelper.failedFuture(new InputRequiredException("storeId is required!")); + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption( + HttpMethod.DELETE, StringUtils.format("memory_stores/%s", storeId), baseUrl); + String resolvedKey = apiKey != null ? apiKey : this.apiKey; + return AsyncHelper.callAsync( + api, + GeneralGetParam.builder() + .apiKey(resolvedKey) + .headers(headers != null ? headers : new HashMap<>()) + .build(), + opt) + .thenApply(r -> FlattenResultBase.fromDashScopeResult(r, AgentStudioDeletionStatus.class)); + } + + /** + * Depth emulation: files more than {@code depth} segments below {@code pathPrefix} are dropped + * and directory prefixes are synthesized — files cut by the bound still contribute their frontier + * directory, matching the native {@code depth=1} behavior. Kept files and prefixes are both per + * page. + */ + private static void filterByDepth(CursorPage page, int depth, String pathPrefix) { + String base = pathPrefix == null || pathPrefix.isEmpty() ? "/" : pathPrefix; + if (!base.endsWith("/")) { + base = base + "/"; + } + List kept = new ArrayList<>(); + TreeSet dirs = new TreeSet<>(); + List data = page.getData() != null ? page.getData() : Collections.emptyList(); + for (Memory memory : data) { + String path = memory.getPath() == null ? "" : memory.getPath(); + String rel = path.startsWith(base) ? path.substring(base.length()) : path; + List segs = new ArrayList<>(); + for (String segment : rel.split("/")) { + if (!segment.isEmpty()) { + segs.add(segment); + } + } + if (segs.size() <= depth) { + kept.add(memory); + } + for (int level = 1; level <= Math.min(segs.size() - 1, depth); level++) { + dirs.add(base + String.join("/", segs.subList(0, level)) + "/"); + } + } + page.setData(kept); + page.setPrefixes(new ArrayList<>(dirs)); + } + + /** Memories (files) inside a memory store. */ + public final class Memories { + + public Memory create(String storeId, MemoryCreateParam param) { + return AsyncHelper.joinAndUnwrap(createAsync(storeId, param)); + } + + public Memory retrieve(String storeId, String memoryId) { + return retrieve(storeId, memoryId, null, null); + } + + public Memory retrieve( + String storeId, String memoryId, String apiKey, Map headers) { + return AsyncHelper.joinAndUnwrap(retrieveAsync(storeId, memoryId, apiKey, headers)); + } + + public Memory update(String storeId, String memoryId, MemoryUpdateParam param) { + return AsyncHelper.joinAndUnwrap(updateAsync(storeId, memoryId, param)); + } + + public CursorPage list(String storeId, MemoryListParam param) { + return AsyncHelper.joinAndUnwrap(listAsync(storeId, param)); + } + + public AgentStudioDeletionStatus delete(String storeId, String memoryId) { + return delete(storeId, memoryId, null, null); + } + + public AgentStudioDeletionStatus delete( + String storeId, String memoryId, String apiKey, Map headers) { + return AsyncHelper.joinAndUnwrap(deleteAsync(storeId, memoryId, apiKey, headers)); + } + + public CompletableFuture createAsync(String storeId, MemoryCreateParam param) { + if (storeId == null || storeId.isEmpty()) { + return AsyncHelper.failedFuture(new InputRequiredException("storeId is required!")); + } + if (param == null) { + return AsyncHelper.failedFuture(new InputRequiredException("param is required!")); + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption( + HttpMethod.POST, StringUtils.format("memory_stores/%s/memories", storeId), baseUrl); + return AsyncHelper.callAsync(api, AgentStudioConstants.withApiKey(apiKey, param), opt) + .thenApply(r -> FlattenResultBase.fromDashScopeResult(r, Memory.class)); + } + + public CompletableFuture retrieveAsync(String storeId, String memoryId) { + return retrieveAsync(storeId, memoryId, null, null); + } + + public CompletableFuture retrieveAsync( + String storeId, String memoryId, String apiKey, Map headers) { + if (storeId == null || storeId.isEmpty() || memoryId == null || memoryId.isEmpty()) { + return AsyncHelper.failedFuture( + new InputRequiredException("storeId and memoryId are required!")); + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption( + HttpMethod.GET, + StringUtils.format("memory_stores/%s/memories/%s", storeId, memoryId), + baseUrl); + String resolvedKey = apiKey != null ? apiKey : MemoryStores.this.apiKey; + return AsyncHelper.callAsync( + api, + GeneralGetParam.builder() + .apiKey(resolvedKey) + .headers(headers != null ? headers : new HashMap<>()) + .build(), + opt) + .thenApply(r -> FlattenResultBase.fromDashScopeResult(r, Memory.class)); + } + + public CompletableFuture updateAsync( + String storeId, String memoryId, MemoryUpdateParam param) { + if (storeId == null || storeId.isEmpty() || memoryId == null || memoryId.isEmpty()) { + return AsyncHelper.failedFuture( + new InputRequiredException("storeId and memoryId are required!")); + } + if (param == null) { + return AsyncHelper.failedFuture(new InputRequiredException("param is required!")); + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption( + HttpMethod.POST, + StringUtils.format("memory_stores/%s/memories/%s", storeId, memoryId), + baseUrl); + return AsyncHelper.callAsync(api, AgentStudioConstants.withApiKey(apiKey, param), opt) + .thenApply(r -> FlattenResultBase.fromDashScopeResult(r, Memory.class)); + } + + public CompletableFuture> listAsync(String storeId, MemoryListParam param) { + if (storeId == null || storeId.isEmpty()) { + return AsyncHelper.failedFuture(new InputRequiredException("storeId is required!")); + } + if (param == null) { + return AsyncHelper.failedFuture(new InputRequiredException("param is required!")); + } + boolean depthEmulated = param.getDepth() != null && param.getDepth() > MAX_WIRE_DEPTH; + // depth >= 2 must not reach the wire; everything else passes through. + MemoryListParam wireParam = param; + if (depthEmulated) { + wireParam = + MemoryListParam.builder() + .pathPrefix(param.getPathPrefix()) + .view(param.getView()) + .limit(param.getLimit()) + .page(param.getPage()) + .build(); + } + String query = wireParam.toQueryString(); + String path = StringUtils.format("memory_stores/%s/memories", storeId); + if (!query.isEmpty()) { + path = path + "?" + query; + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption(HttpMethod.GET, path, baseUrl); + return AsyncHelper.callAsync( + api, GeneralGetParam.builder().apiKey(apiKey).headers(new HashMap<>()).build(), opt) + .thenApply( + r -> { + Type type = new TypeToken>() {}.getType(); + CursorPage page = FlattenResultBase.fromDashScopeResult(r, type); + page.setFetchNext( + cursor -> + listAsync( + storeId, + MemoryListParam.builder() + .pathPrefix(param.getPathPrefix()) + .depth(param.getDepth()) + .view(param.getView()) + .limit(param.getLimit()) + .page(cursor) + .build())); + if (depthEmulated) { + filterByDepth(page, param.getDepth(), param.getPathPrefix()); + } + return page; + }); + } + + public CompletableFuture deleteAsync( + String storeId, String memoryId) { + return deleteAsync(storeId, memoryId, null, null); + } + + public CompletableFuture deleteAsync( + String storeId, String memoryId, String apiKey, Map headers) { + if (storeId == null || storeId.isEmpty() || memoryId == null || memoryId.isEmpty()) { + return AsyncHelper.failedFuture( + new InputRequiredException("storeId and memoryId are required!")); + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption( + HttpMethod.DELETE, + StringUtils.format("memory_stores/%s/memories/%s", storeId, memoryId), + baseUrl); + String resolvedKey = apiKey != null ? apiKey : MemoryStores.this.apiKey; + return AsyncHelper.callAsync( + api, + GeneralGetParam.builder() + .apiKey(resolvedKey) + .headers(headers != null ? headers : new HashMap<>()) + .build(), + opt) + .thenApply( + r -> FlattenResultBase.fromDashScopeResult(r, AgentStudioDeletionStatus.class)); + } + } + + /** Immutable version snapshots of the memories in a store. */ + public final class MemoryVersions { + + public CursorPage list(String storeId, MemoryVersionListParam param) { + return AsyncHelper.joinAndUnwrap(listAsync(storeId, param)); + } + + public MemoryVersion retrieve(String storeId, String versionId) { + return retrieve(storeId, versionId, null, null); + } + + public MemoryVersion retrieve( + String storeId, String versionId, String apiKey, Map headers) { + return AsyncHelper.joinAndUnwrap(retrieveAsync(storeId, versionId, apiKey, headers)); + } + + public MemoryVersion redact(String storeId, String versionId) { + return AsyncHelper.joinAndUnwrap(redactAsync(storeId, versionId)); + } + + public CompletableFuture> listAsync( + String storeId, MemoryVersionListParam param) { + if (storeId == null || storeId.isEmpty()) { + return AsyncHelper.failedFuture(new InputRequiredException("storeId is required!")); + } + if (param == null) { + return AsyncHelper.failedFuture(new InputRequiredException("param is required!")); + } + String query = param.toQueryString(); + String path = StringUtils.format("memory_stores/%s/memory_versions", storeId); + if (!query.isEmpty()) { + path = path + "?" + query; + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption(HttpMethod.GET, path, baseUrl); + return AsyncHelper.callAsync( + api, GeneralGetParam.builder().apiKey(apiKey).headers(new HashMap<>()).build(), opt) + .thenApply( + r -> { + Type type = new TypeToken>() {}.getType(); + CursorPage page = FlattenResultBase.fromDashScopeResult(r, type); + page.setFetchNext( + cursor -> + listAsync( + storeId, + MemoryVersionListParam.builder() + .memoryId(param.getMemoryId()) + .operation(param.getOperation()) + .createdAtGte(param.getCreatedAtGte()) + .createdAtLte(param.getCreatedAtLte()) + .view(param.getView()) + .limit(param.getLimit()) + .page(cursor) + .build())); + return page; + }); + } + + public CompletableFuture retrieveAsync(String storeId, String versionId) { + return retrieveAsync(storeId, versionId, null, null); + } + + public CompletableFuture retrieveAsync( + String storeId, String versionId, String apiKey, Map headers) { + if (storeId == null || storeId.isEmpty() || versionId == null || versionId.isEmpty()) { + return AsyncHelper.failedFuture( + new InputRequiredException("storeId and versionId are required!")); + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption( + HttpMethod.GET, + StringUtils.format("memory_stores/%s/memory_versions/%s", storeId, versionId), + baseUrl); + String resolvedKey = apiKey != null ? apiKey : MemoryStores.this.apiKey; + return AsyncHelper.callAsync( + api, + GeneralGetParam.builder() + .apiKey(resolvedKey) + .headers(headers != null ? headers : new HashMap<>()) + .build(), + opt) + .thenApply(r -> FlattenResultBase.fromDashScopeResult(r, MemoryVersion.class)); + } + + /** Redact a version: idempotent and irreversible. */ + public CompletableFuture redactAsync(String storeId, String versionId) { + if (storeId == null || storeId.isEmpty() || versionId == null || versionId.isEmpty()) { + return AsyncHelper.failedFuture( + new InputRequiredException("storeId and versionId are required!")); + } + GeneralServiceOption opt = + AgentStudioConstants.newServiceOption( + HttpMethod.POST, + StringUtils.format("memory_stores/%s/memory_versions/%s/redact", storeId, versionId), + baseUrl); + return AsyncHelper.callAsync( + api, + AgentStudioConstants.withApiKey(apiKey, MemoryUpdateParam.builder().build()), + opt) + .thenApply(r -> FlattenResultBase.fromDashScopeResult(r, MemoryVersion.class)); + } + } +} diff --git a/src/test/java/com/alibaba/dashscope/TestAgentStudioMemoryStores.java b/src/test/java/com/alibaba/dashscope/TestAgentStudioMemoryStores.java new file mode 100644 index 00000000..4ca91a65 --- /dev/null +++ b/src/test/java/com/alibaba/dashscope/TestAgentStudioMemoryStores.java @@ -0,0 +1,361 @@ +package com.alibaba.dashscope; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.alibaba.dashscope.agentstudio.AgentStudioClient; +import com.alibaba.dashscope.agentstudio.model.AgentStudioDeletionStatus; +import com.alibaba.dashscope.agentstudio.model.Memory; +import com.alibaba.dashscope.agentstudio.model.MemoryStore; +import com.alibaba.dashscope.agentstudio.model.MemoryVersion; +import com.alibaba.dashscope.agentstudio.pagination.CursorPage; +import com.alibaba.dashscope.agentstudio.param.MemoryCreateParam; +import com.alibaba.dashscope.agentstudio.param.MemoryListParam; +import com.alibaba.dashscope.agentstudio.param.MemoryStoreCreateParam; +import com.alibaba.dashscope.agentstudio.param.MemoryStoreListParam; +import com.alibaba.dashscope.agentstudio.param.MemoryStoreUpdateParam; +import com.alibaba.dashscope.agentstudio.param.MemoryUpdateParam; +import com.alibaba.dashscope.agentstudio.param.MemoryVersionListParam; +import com.alibaba.dashscope.exception.ApiException; +import com.alibaba.dashscope.utils.JsonUtils; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Collections; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Execution(ExecutionMode.SAME_THREAD) +public class TestAgentStudioMemoryStores { + private static MockWebServer mockServer; + private static JsonObject fixtures; + private static String baseUrl; + private static AgentStudioClient client; + + @BeforeAll + public static void before() throws IOException { + mockServer = new MockWebServer(); + mockServer.start(); + baseUrl = mockServer.url("/api/v1/agentstudio/").toString(); + byte[] content = Files.readAllBytes(Paths.get("./src/test/resources/agentstudio.json")); + fixtures = JsonUtils.parse(new String(content, StandardCharsets.UTF_8)); + client = new AgentStudioClient("test-key", baseUrl, "ws_01", null, null); + } + + @AfterAll + public static void after() throws IOException { + mockServer.close(); + } + + private void enqueue(String fixtureKey) { + JsonObject response = fixtures.get(fixtureKey).getAsJsonObject(); + mockServer.enqueue(TestUtils.createMockResponse(JsonUtils.toJson(response), 200)); + } + + @Test + public void testStoreCreatePostsBody() throws Exception { + enqueue("memory_store_response"); + MemoryStore store = + client + .memoryStores() + .create( + MemoryStoreCreateParam.builder() + .name("User Preferences") + .description("Per-user preferences.") + .metadata(Collections.singletonMap("owner", "demo")) + .build()); + RecordedRequest request = mockServer.takeRequest(); + JsonObject body = JsonUtils.parse(request.getBody().readUtf8()); + assertEquals("POST", request.getMethod()); + assertTrue(request.getPath().endsWith("/memory_stores")); + assertEquals("User Preferences", body.get("name").getAsString()); + assertEquals("Per-user preferences.", body.get("description").getAsString()); + assertEquals("demo", body.getAsJsonObject("metadata").get("owner").getAsString()); + assertEquals("memstore_01Hx", store.getId()); + assertEquals("memory_store", store.getType()); + assertNull(store.getArchivedAt()); + } + + @Test + public void testStoreUpdatePartialBody() throws Exception { + enqueue("memory_store_response"); + client + .memoryStores() + .update("memstore_01Hx", MemoryStoreUpdateParam.builder().name("Renamed").build()); + RecordedRequest request = mockServer.takeRequest(); + JsonObject body = JsonUtils.parse(request.getBody().readUtf8()); + assertEquals("POST", request.getMethod()); + assertTrue(request.getPath().endsWith("/memory_stores/memstore_01Hx")); + assertEquals(1, body.size()); + assertEquals("Renamed", body.get("name").getAsString()); + } + + @Test + public void testStoreUpdateEmptyMetadataClears() throws Exception { + // metadata is replaced wholesale: an empty map must reach the wire + // (an isEmpty() guard would silently turn "clear" into a no-op). + enqueue("memory_store_response"); + client + .memoryStores() + .update( + "memstore_01Hx", + MemoryStoreUpdateParam.builder().metadata(Collections.emptyMap()).build()); + RecordedRequest request = mockServer.takeRequest(); + JsonObject body = JsonUtils.parse(request.getBody().readUtf8()); + assertEquals("POST", request.getMethod()); + assertTrue(body.has("metadata")); + assertEquals(0, body.getAsJsonObject("metadata").size()); + } + + @Test + public void testStoreListQueryParams() throws Exception { + enqueue("memory_store_list_response"); + client + .memoryStores() + .list(MemoryStoreListParam.builder().includeArchived(true).limit(10).page("tok_1").build()); + RecordedRequest request = mockServer.takeRequest(); + String path = request.getPath(); + assertEquals("GET", request.getMethod()); + assertTrue(path.split("\\?")[0].endsWith("/memory_stores")); + assertTrue(path.contains("include_archived=true")); + assertTrue(path.contains("limit=10")); + assertTrue(path.contains("page=tok_1")); + } + + @Test + public void testStoreArchiveAndDelete() throws Exception { + enqueue("memory_store_response"); + MemoryStore archived = client.memoryStores().archive("memstore_01Hx"); + RecordedRequest archiveRequest = mockServer.takeRequest(); + assertEquals("POST", archiveRequest.getMethod()); + assertTrue(archiveRequest.getPath().endsWith("/memory_stores/memstore_01Hx/archive")); + assertNotNull(archived); + + enqueue("delete_response"); + AgentStudioDeletionStatus deleted = client.memoryStores().delete("memstore_01Hx"); + RecordedRequest deleteRequest = mockServer.takeRequest(); + assertEquals("DELETE", deleteRequest.getMethod()); + assertTrue(deleteRequest.getPath().endsWith("/memory_stores/memstore_01Hx")); + assertTrue(deleted.isDeleted()); + } + + @Test + public void testMemoryCreateAndRetrieve() throws Exception { + enqueue("memory_created_response"); + Memory memory = + client + .memoryStores() + .memories() + .create( + "memstore_01Hx", + MemoryCreateParam.builder() + .path("/formatting.md") + .content("Use GAAP formatting.") + .build()); + RecordedRequest request = mockServer.takeRequest(); + JsonObject body = JsonUtils.parse(request.getBody().readUtf8()); + assertEquals("POST", request.getMethod()); + assertTrue(request.getPath().endsWith("/memory_stores/memstore_01Hx/memories")); + assertEquals("/formatting.md", body.get("path").getAsString()); + assertEquals("Use GAAP formatting.", body.get("content").getAsString()); + assertEquals("mem_01Hy", memory.getId()); + assertEquals("memstore_01Hx", memory.getMemoryStoreId()); + // Create responses never include content (probe-verified wire shape). + assertNull(memory.getContent()); + + enqueue("memory_response"); + Memory retrieved = client.memoryStores().memories().retrieve("memstore_01Hx", "mem_01Hy"); + RecordedRequest retrieveRequest = mockServer.takeRequest(); + assertEquals("GET", retrieveRequest.getMethod()); + assertTrue( + retrieveRequest.getPath().endsWith("/memory_stores/memstore_01Hx/memories/mem_01Hy")); + assertEquals("Use GAAP formatting.", retrieved.getContent()); + } + + @Test + public void testMemoryUpdateFlattenedPrecondition() throws Exception { + enqueue("memory_created_response"); + client + .memoryStores() + .memories() + .update( + "memstore_01Hx", + "mem_01Hy", + MemoryUpdateParam.builder().content("CORRECTED").contentSha256("abc123").build()); + RecordedRequest request = mockServer.takeRequest(); + JsonObject body = JsonUtils.parse(request.getBody().readUtf8()); + assertEquals("POST", request.getMethod()); + assertTrue(request.getPath().endsWith("/memory_stores/memstore_01Hx/memories/mem_01Hy")); + assertEquals("CORRECTED", body.get("content").getAsString()); + assertEquals("abc123", body.get("content_sha256").getAsString()); + } + + @Test + public void testMemoryListAutoPaginates() throws Exception { + enqueue("memory_list_response"); + enqueue("memory_list_page2_response"); + CursorPage page = + client + .memoryStores() + .memories() + .list( + "memstore_01Hx", + MemoryListParam.builder().pathPrefix("/").depth(1).view("basic").build()); + RecordedRequest request = mockServer.takeRequest(); + String path = request.getPath(); + assertEquals("GET", request.getMethod()); + assertTrue(path.split("\\?")[0].endsWith("/memory_stores/memstore_01Hx/memories")); + assertTrue(path.contains("path_prefix=%2F")); + assertTrue(path.contains("depth=1")); + assertTrue(path.contains("view=basic")); + // Native depth=1: gson fills prefixes from the envelope. + assertEquals(Collections.singletonList("/notes/"), page.getPrefixes()); + + // Iterating drains the queued second page; the fetchNext closure must + // rebuild the full filter set with the cursor. + java.util.List ids = new java.util.ArrayList<>(); + for (Memory item : page) { + ids.add(item.getId()); + } + assertEquals(java.util.Arrays.asList("mem_01Hy", "mem_02"), ids); + RecordedRequest second = mockServer.takeRequest(); + assertTrue(second.getPath().contains("page=tok_2")); + assertTrue(second.getPath().contains("path_prefix=%2F")); + assertTrue(second.getPath().contains("depth=1")); + } + + @Test + public void testMemoryListDepthTwoEmulatedAcrossPages() throws Exception { + enqueue("memory_flat_list_response"); + enqueue("memory_flat_list_page2_response"); + CursorPage page = + client + .memoryStores() + .memories() + .list("memstore_01Hx", MemoryListParam.builder().depth(2).build()); + RecordedRequest request = mockServer.takeRequest(); + // depth >= 2 must not reach the wire; the view is derived locally. + assertFalse(request.getPath().contains("depth=")); + + // Page 1: the 3-segment file is cut. Page 2: adds one 1-segment file, + // drops a 3-segment one; the second request still carries no depth + // (fetchNext re-enters the emulation with the original depth). + java.util.List paths = new java.util.ArrayList<>(); + for (Memory item : page) { + paths.add(item.getPath()); + } + assertEquals(java.util.Arrays.asList("/root.md", "/notes/top.md", "/flat2.md"), paths); + // Per-page synthesized prefixes: the cut 3-segment file leaves its + // frontier directory. + assertEquals(java.util.Arrays.asList("/notes/", "/notes/deep/"), page.getPrefixes()); + RecordedRequest second = mockServer.takeRequest(); + assertFalse(second.getPath().contains("depth=")); + } + + @Test + public void testMemoryListDepthOnePassesThrough() throws Exception { + enqueue("memory_flat_list_response"); + client + .memoryStores() + .memories() + .list("memstore_01Hx", MemoryListParam.builder().depth(1).build()); + RecordedRequest request = mockServer.takeRequest(); + assertTrue(request.getPath().contains("depth=1")); + } + + @Test + public void testMemoryErrorPathParsesNestedErrorBody() throws Exception { + // Live-wire shape: nested error envelope on a 409. OkHttpHttpClient's + // parseFailedJson fallback must surface code/message to the user. + String body = + "{\"type\":\"error\",\"error\":{\"code\":\"memory_path_conflict_error\"," + + "\"message\":\"Memory path already exists.\"},\"request_id\":\"req-e1\"}"; + mockServer.enqueue(TestUtils.createMockResponse(body, 409)); + ApiException ex = + assertThrows( + ApiException.class, + () -> + client + .memoryStores() + .memories() + .create( + "memstore_01Hx", + MemoryCreateParam.builder() + .path("/e2e/notes.md") + .content("duplicate") + .build())); + assertEquals(409, ex.getStatus().getStatusCode()); + assertEquals("memory_path_conflict_error", ex.getStatus().getCode()); + // Drain the recorded request: a test that never takeRequest()s leaves + // the shared server's request queue off by one and misaligns every + // later test's assertions. + assertEquals("POST", mockServer.takeRequest().getMethod()); + } + + @Test + public void testMemoryDelete() throws Exception { + enqueue("memory_delete_response"); + AgentStudioDeletionStatus deleted = + client.memoryStores().memories().delete("memstore_01Hx", "mem_01Hy"); + RecordedRequest request = mockServer.takeRequest(); + assertEquals("DELETE", request.getMethod()); + assertTrue(request.getPath().endsWith("/memory_stores/memstore_01Hx/memories/mem_01Hy")); + assertEquals("memory_deleted", deleted.getType()); + } + + @Test + public void testVersionListDottedTimeKeysAndRedact() throws Exception { + enqueue("memory_version_list_response"); + client + .memoryStores() + .memoryVersions() + .list( + "memstore_01Hx", + MemoryVersionListParam.builder() + .memoryId("mem_01Hy") + .operation("modified") + .createdAtGte("2026-10-01T00:00:00Z") + .createdAtLte("2026-10-31T00:00:00Z") + .view("full") + .build()); + RecordedRequest request = mockServer.takeRequest(); + String path = request.getPath(); + assertEquals("GET", request.getMethod()); + assertTrue(path.split("\\?")[0].endsWith("/memory_stores/memstore_01Hx/memory_versions")); + assertTrue(path.contains("memory_id=mem_01Hy")); + assertTrue(path.contains("operation=modified")); + assertTrue(path.contains("created_at.gte=2026-10-01T00%3A00%3A00Z")); + assertTrue(path.contains("created_at.lte=2026-10-31T00%3A00%3A00Z")); + assertTrue(path.contains("view=full")); + + enqueue("memory_version_response"); + MemoryVersion version = + client.memoryStores().memoryVersions().retrieve("memstore_01Hx", "memver_01Hz"); + RecordedRequest retrieveRequest = mockServer.takeRequest(); + assertEquals( + "/api/v1/agentstudio/memory_stores/memstore_01Hx/memory_versions/memver_01Hz", + retrieveRequest.getPath()); + + enqueue("memory_version_response"); + MemoryVersion redacted = + client.memoryStores().memoryVersions().redact("memstore_01Hx", "memver_01Hz"); + RecordedRequest redactRequest = mockServer.takeRequest(); + assertEquals("POST", redactRequest.getMethod()); + assertTrue( + redactRequest + .getPath() + .endsWith("/memory_stores/memstore_01Hx/memory_versions/memver_01Hz/redact")); + assertEquals("memver_01Hz", redacted.getId()); + } +} diff --git a/src/test/resources/agentstudio.json b/src/test/resources/agentstudio.json index 495a6d6b..a8babaae 100644 --- a/src/test/resources/agentstudio.json +++ b/src/test/resources/agentstudio.json @@ -4,13 +4,21 @@ "type": "agent", "name": "test-agent", "description": "A test agent", - "model": {"id": "qwen-max"}, + "model": { + "id": "qwen-max" + }, "system": "You are a helpful assistant.", - "tools": [{"type": "bash_tool"}], + "tools": [ + { + "type": "bash_tool" + } + ], "skills": [], "mcp_servers": [], "version": 2, - "metadata": {"env": "test"}, + "metadata": { + "env": "test" + }, "workspace_id": "ws_001", "archived_at": null, "created_at": "2025-01-01T00:00:00Z", @@ -42,13 +50,23 @@ { "agent_id": "agent_xyz", "version": 1, - "config": {"name": "test-agent", "model": {"id": "qwen-max"}}, + "config": { + "name": "test-agent", + "model": { + "id": "qwen-max" + } + }, "created_at": "2025-01-01T00:00:00Z" }, { "agent_id": "agent_xyz", "version": 2, - "config": {"name": "test-agent-v2", "model": {"id": "qwen-plus"}}, + "config": { + "name": "test-agent-v2", + "model": { + "id": "qwen-plus" + } + }, "created_at": "2025-01-02T00:00:00Z" } ], @@ -65,7 +83,9 @@ "version": 1, "name": "test-agent", "description": null, - "model": {"id": "qwen-max"}, + "model": { + "id": "qwen-max" + }, "system": "You are a test agent.", "tools": [] }, @@ -82,7 +102,9 @@ "speed": 42.5 }, "vault_ids": [], - "metadata": {"env": "test"}, + "metadata": { + "env": "test" + }, "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z", "request_id": "req-001" @@ -92,13 +114,21 @@ { "id": "sess_1", "status": "idle", - "agent": {"id": "agent_xyz", "type": "agent", "version": 1}, + "agent": { + "id": "agent_xyz", + "type": "agent", + "version": 1 + }, "created_at": "2025-01-01T00:00:00Z" }, { "id": "sess_2", "status": "running", - "agent": {"id": "agent_xyz", "type": "agent", "version": 1}, + "agent": { + "id": "agent_xyz", + "type": "agent", + "version": 1 + }, "created_at": "2025-01-02T00:00:00Z" } ], @@ -113,11 +143,20 @@ "status": "active", "config": { "type": "sandbox", - "networking": {"type": "public"}, - "packages": {"pip": ["requests", "numpy"]} + "networking": { + "type": "public" + }, + "packages": { + "pip": [ + "requests", + "numpy" + ] + } }, "scope": "organization", - "metadata": {"team": "infra"}, + "metadata": { + "team": "infra" + }, "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z", "request_id": "req-003" @@ -184,7 +223,9 @@ "id": "vault_001", "type": "vault", "display_name": "Test Vault", - "metadata": {"team": "security"}, + "metadata": { + "team": "security" + }, "archived_at": null, "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z", @@ -213,8 +254,13 @@ "type": "vault_credential", "vault_id": "vault_001", "display_name": "Test Credential", - "auth": {"type": "environment_variable", "secret_name": "DASHSCOPE_API_KEY"}, - "metadata": {"service": "openai"}, + "auth": { + "type": "environment_variable", + "secret_name": "DASHSCOPE_API_KEY" + }, + "metadata": { + "service": "openai" + }, "archived_at": null, "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z", @@ -288,7 +334,12 @@ "type": "message", "status": "completed", "role": "user", - "content": [{"type": "text", "text": "hello"}], + "content": [ + { + "type": "text", + "text": "hello" + } + ], "sequence_number": 1, "created_at": "2025-01-01T00:00:00Z" }, @@ -298,7 +349,12 @@ "type": "message", "status": "completed", "role": "assistant", - "content": [{"type": "text", "text": "hi there"}], + "content": [ + { + "type": "text", + "text": "hi there" + } + ], "sequence_number": 2, "created_at": "2025-01-01T00:00:01Z" } @@ -316,7 +372,9 @@ "type": "agent", "version": 12, "name": "data-analyst", - "model": {"id": "qwen3-max"}, + "model": { + "id": "qwen3-max" + }, "system": "Analyze data." }, "environment_id": "env_01", @@ -331,19 +389,37 @@ { "type": "message", "role": "user", - "content": [{"type": "text", "text": "Summarize yesterday's orders"}] + "content": [ + { + "type": "text", + "text": "Summarize yesterday's orders" + } + ] } ], "resources": [ - {"type": "file", "file_id": "file_01", "mount_path": "/mnt/data"} + { + "type": "file", + "file_id": "file_01", + "mount_path": "/mnt/data" + } + ], + "vault_ids": [ + "vault_01" ], - "vault_ids": ["vault_01"], - "environment_variables": {"API_BASE_URL": "https://example.test"}, - "metadata": {"biz": "summary"}, + "environment_variables": { + "API_BASE_URL": "https://example.test" + }, + "metadata": { + "biz": "summary" + }, "status": "paused", "paused_reason": { "type": "error", - "error": {"code": "RUN_FAILED", "message": "failed"} + "error": { + "code": "RUN_FAILED", + "message": "failed" + } }, "archived_at": null, "created_at": "2026-07-27T01:00:00Z", @@ -361,7 +437,9 @@ "type": "agent", "version": 12, "name": "data-analyst", - "model": {"id": "qwen3-max"} + "model": { + "id": "qwen3-max" + } }, "status": "active", "created_at": "2026-07-27T01:00:00Z", @@ -375,11 +453,17 @@ "id": "drun_01", "type": "deployment_run", "deployment_id": "depl_01", - "agent": {"id": "agent_01", "version": 12}, + "agent": { + "id": "agent_01", + "version": 12 + }, "session_id": "session_01", "trigger_source": "manual", "status": "failed", - "error": {"code": "RUN_FAILED", "message": "failed"}, + "error": { + "code": "RUN_FAILED", + "message": "failed" + }, "started_at": "2026-07-27T01:00:00.123Z", "finished_at": "2026-07-27T01:01:00.456Z", "requestId": "req-run-01" @@ -390,7 +474,10 @@ "id": "drun_01", "type": "deployment_run", "deployment_id": "depl_01", - "agent": {"id": "agent_01", "version": 12}, + "agent": { + "id": "agent_01", + "version": 12 + }, "session_id": "session_01", "trigger_source": "manual", "status": "succeeded", @@ -401,5 +488,149 @@ ], "next_page": "next-run", "requestId": "req-run-list" + }, + "memory_store_response": { + "id": "memstore_01Hx", + "type": "memory_store", + "name": "User Preferences", + "description": "Per-user preferences.", + "metadata": { + "owner": "demo" + }, + "archived_at": null, + "created_at": "2026-10-01T00:00:00Z", + "updated_at": "2026-10-01T00:00:00Z", + "request_id": "req-m01" + }, + "memory_store_list_response": { + "data": [ + { + "id": "memstore_01Hx", + "type": "memory_store", + "name": "User Preferences" + } + ], + "next_page": null, + "request_id": "req-m02" + }, + "memory_response": { + "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", + "request_id": "req-m03" + }, + "memory_list_response": { + "data": [ + { + "id": "mem_01Hy", + "type": "memory", + "memory_store_id": "memstore_01Hx", + "path": "/e2e/notes.md", + "content_sha256": "abc123" + } + ], + "prefixes": [ + "/notes/" + ], + "next_page": "tok_2", + "request_id": "req-m04" + }, + "memory_flat_list_response": { + "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": "tok_3", + "request_id": "req-m05" + }, + "memory_version_response": { + "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", + "request_id": "req-m06" + }, + "memory_version_list_response": { + "data": [ + { + "id": "memver_01Hz", + "type": "memory_version", + "operation": "modified" + } + ], + "next_page": null, + "request_id": "req-m07" + }, + "memory_delete_response": { + "id": "mem_01Hy", + "type": "memory_deleted", + "request_id": "req-m08" + }, + "memory_list_page2_response": { + "data": [ + { + "id": "mem_02", + "type": "memory", + "memory_store_id": "memstore_01Hx", + "path": "/e2e/other.md" + } + ], + "prefixes": [], + "next_page": null, + "request_id": "req-m09" + }, + "memory_created_response": { + "id": "mem_01Hy", + "type": "memory", + "memory_store_id": "memstore_01Hx", + "path": "/formatting.md", + "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", + "request_id": "req-m10" + }, + "memory_flat_list_page2_response": { + "data": [ + { + "id": "mem_4", + "type": "memory", + "path": "/flat2.md" + }, + { + "id": "mem_5", + "type": "memory", + "path": "/notes/x/deep.md" + } + ], + "next_page": null, + "request_id": "req-m11" } -} +} \ No newline at end of file