From ecd8baa790e975e5a4f097fdb63a1f8cf04e2e9d Mon Sep 17 00:00:00 2001 From: Irina Bednova Date: Wed, 22 Jul 2026 13:25:43 +0100 Subject: [PATCH 1/3] [conversations] add bulk upload conversation memories endpoint Adds POST /conversations/{id}/memories to upload a batch of memories scoped to a conversation for the agent to search over on demand. Co-Authored-By: Claude Fable 5 --- examples/memories/run.py | 38 +++++++++++++ .../_conversation_memories_upload.py | 51 +++++++++++++++++ src/gradient_labs/client.py | 23 ++++++++ tests/test_conversation_memories_upload.py | 55 +++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 examples/memories/run.py create mode 100644 src/gradient_labs/_conversation_memories_upload.py create mode 100644 tests/test_conversation_memories_upload.py diff --git a/examples/memories/run.py b/examples/memories/run.py new file mode 100644 index 0000000..164a0be --- /dev/null +++ b/examples/memories/run.py @@ -0,0 +1,38 @@ +import os +import sys +import uuid +import logging + +from gradient_labs import ( + Client, + BulkUploadMemoriesParams, +) + +logging.basicConfig(stream=sys.stdout, level=logging.INFO) + +client = Client( + api_key=os.environ["GLABS_API_KEY"], + base_url="http://localhost:4000", +) + +# Each memory is an arbitrary JSON object stored verbatim for the AI agent to +# search over on demand. created_at_keys lists the payload keys tried in order +# to read each memory's timestamp; when none match, the upload time is used. +rsp = client.bulk_upload_conversation_memories( + conversation_id="conv_01ham6bzcdeja9xzqhjf6daq30", + params=BulkUploadMemoriesParams( + idempotency_key=str(uuid.uuid4()), + memories=[ + { + "kind": "preference", + "channel": "email", + "occurred_at": "2026-01-02T10:00:00Z", + }, + {"kind": "order", "order_id": "order-456", "total": 42.0}, + ], + created_at_keys=["occurred_at"], + ), +) +logging.info( + f"✅ Memories uploaded: {rsp.memories_inserted} inserted (upload {rsp.upload_id})" +) diff --git a/src/gradient_labs/_conversation_memories_upload.py b/src/gradient_labs/_conversation_memories_upload.py new file mode 100644 index 0000000..468a479 --- /dev/null +++ b/src/gradient_labs/_conversation_memories_upload.py @@ -0,0 +1,51 @@ +from typing import Optional, List, Dict, Any + +from dataclasses import dataclass +from dataclasses_json import dataclass_json + +from ._http_client import HttpClient + + +@dataclass_json +@dataclass(frozen=True) +class BulkUploadMemoriesParams: + # idempotency_key de-duplicates retries of the same upload. Re-uploading with + # the same key returns the original upload instead of inserting again. + idempotency_key: str + + # memories is the list of individual memories to store. Each element is an + # arbitrary JSON object stored verbatim as the memory's raw payload. + memories: List[Dict[str, Any]] + + # created_at_keys optionally lists JSON keys tried in order to read each + # memory's timestamp from its payload. When none match, the upload time is used. + created_at_keys: Optional[List[str]] = None + + +@dataclass_json +@dataclass(frozen=True) +class BulkUploadMemoriesResponse: + # upload_id identifies the upload that stored these memories. + upload_id: str + + # memories_inserted is the number of memories that were stored. + memories_inserted: int + + +def bulk_upload_conversation_memories( + *, client: HttpClient, conversation_id: str, params: BulkUploadMemoriesParams +) -> BulkUploadMemoriesResponse: + """bulk_upload_conversation_memories stores a batch of memories scoped to a + conversation, for the AI agent to search over on demand.""" + body: Dict[str, Any] = { + "idempotency_key": params.idempotency_key, + "memories": params.memories, + } + if params.created_at_keys is not None: + body["created_at_keys"] = params.created_at_keys + + rsp = client.post( + path=f"conversations/{conversation_id}/memories", + body=body, + ) + return BulkUploadMemoriesResponse.from_dict(rsp) diff --git a/src/gradient_labs/client.py b/src/gradient_labs/client.py index eb8a1d6..0696121 100644 --- a/src/gradient_labs/client.py +++ b/src/gradient_labs/client.py @@ -30,6 +30,11 @@ return_async_tool_result, ReturnAsyncToolResultParams, ) +from ._conversation_memories_upload import ( + bulk_upload_conversation_memories, + BulkUploadMemoriesParams, + BulkUploadMemoriesResponse, +) from ._outbound_conversation_start import ( start_outbound_conversation, @@ -395,6 +400,24 @@ def return_async_tool_result( params=params, ) + def bulk_upload_conversation_memories( + self, + *, + conversation_id: str, + params: BulkUploadMemoriesParams, + ) -> BulkUploadMemoriesResponse: + """bulk_upload_conversation_memories stores a batch of memories scoped to a + conversation, for the AI agent to search over on demand. + + Each memory is stored verbatim as an arbitrary JSON object. Re-uploading + with the same idempotency_key returns the original upload instead of + inserting again.""" + return bulk_upload_conversation_memories( + client=self.http_client, + conversation_id=conversation_id, + params=params, + ) + def upsert_hand_off_target(self, *, params: UpsertHandOffTargetParams) -> None: """upsert_hand_off_target inserts or updates a hand-off target. diff --git a/tests/test_conversation_memories_upload.py b/tests/test_conversation_memories_upload.py new file mode 100644 index 0000000..a361bb6 --- /dev/null +++ b/tests/test_conversation_memories_upload.py @@ -0,0 +1,55 @@ +from unittest.mock import MagicMock + +from gradient_labs import Client, BulkUploadMemoriesParams + +CONVERSATION_ID = "conv_01ham6bzcdeja9xzqhjf6daq30" + + +def _client(response): + client = Client(api_key="test-key") + post = MagicMock(return_value=response) + client.http_client.post = post + return client, post + + +def test_bulk_upload_conversation_memories(): + client, post = _client( + {"upload_id": "upl_123", "memories_inserted": 2}, + ) + + rsp = client.bulk_upload_conversation_memories( + conversation_id=CONVERSATION_ID, + params=BulkUploadMemoriesParams( + idempotency_key="key-1", + memories=[{"note": "prefers email"}, {"tier": "premium"}], + ), + ) + + _, kwargs = post.call_args + assert kwargs["path"] == f"conversations/{CONVERSATION_ID}/memories" + body = kwargs["body"] + assert body["idempotency_key"] == "key-1" + assert body["memories"] == [{"note": "prefers email"}, {"tier": "premium"}] + assert "created_at_keys" not in body + + assert rsp.upload_id == "upl_123" + assert rsp.memories_inserted == 2 + + +def test_bulk_upload_conversation_memories_includes_created_at_keys(): + client, post = _client( + {"upload_id": "upl_456", "memories_inserted": 1}, + ) + + client.bulk_upload_conversation_memories( + conversation_id=CONVERSATION_ID, + params=BulkUploadMemoriesParams( + idempotency_key="key-2", + memories=[{"event": "signup"}], + created_at_keys=["occurred_at", "created_at"], + ), + ) + + _, kwargs = post.call_args + body = kwargs["body"] + assert body["created_at_keys"] == ["occurred_at", "created_at"] From c284195a2f01a8d3d6b630cf742782d549103839 Mon Sep 17 00:00:00 2001 From: Irina Bednova Date: Wed, 22 Jul 2026 13:25:56 +0100 Subject: [PATCH 2/3] [release] bump version to 0.13.0 Co-Authored-By: Claude Fable 5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3a468d1..8e86cce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gradient-labs" -version = "0.12.1" +version = "0.13.0" description = "Python bindings for the Gradient Labs API" readme = "README.md" requires-python = ">=3.9,<4.0" From eebb67ec5f14cf45fcb02271d1dbecd4c892a510 Mon Sep 17 00:00:00 2001 From: Irina Bednova Date: Mon, 27 Jul 2026 16:42:53 +0100 Subject: [PATCH 3/3] sync memories endpoint to customer batch-create Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/memories/run.py | 47 ++++++------- .../_conversation_memories_upload.py | 51 -------------- .../_customer_memories_batch_create.py | 59 ++++++++++++++++ src/gradient_labs/client.py | 32 ++++----- tests/test_conversation_memories_upload.py | 55 --------------- tests/test_customer_memories_batch_create.py | 68 +++++++++++++++++++ 6 files changed, 167 insertions(+), 145 deletions(-) delete mode 100644 src/gradient_labs/_conversation_memories_upload.py create mode 100644 src/gradient_labs/_customer_memories_batch_create.py delete mode 100644 tests/test_conversation_memories_upload.py create mode 100644 tests/test_customer_memories_batch_create.py diff --git a/examples/memories/run.py b/examples/memories/run.py index 164a0be..8508280 100644 --- a/examples/memories/run.py +++ b/examples/memories/run.py @@ -1,38 +1,39 @@ import os import sys -import uuid import logging +from datetime import datetime from gradient_labs import ( Client, - BulkUploadMemoriesParams, + CustomerMemory, ) logging.basicConfig(stream=sys.stdout, level=logging.INFO) client = Client( api_key=os.environ["GLABS_API_KEY"], - base_url="http://localhost:4000", + base_url=os.environ.get("GRADIENT_LABS_BASE_URL", "http://localhost:4000"), ) -# Each memory is an arbitrary JSON object stored verbatim for the AI agent to -# search over on demand. created_at_keys lists the payload keys tried in order -# to read each memory's timestamp; when none match, the upload time is used. -rsp = client.bulk_upload_conversation_memories( - conversation_id="conv_01ham6bzcdeja9xzqhjf6daq30", - params=BulkUploadMemoriesParams( - idempotency_key=str(uuid.uuid4()), - memories=[ - { - "kind": "preference", - "channel": "email", - "occurred_at": "2026-01-02T10:00:00Z", - }, - {"kind": "order", "order_id": "order-456", "total": 42.0}, - ], - created_at_keys=["occurred_at"], - ), -) -logging.info( - f"✅ Memories uploaded: {rsp.memories_inserted} inserted (upload {rsp.upload_id})" +# Each memory is scoped to a customer and stores an arbitrary JSON object in +# `data`, kept verbatim for the AI agent to search over on demand. The call is +# asynchronous ("fire and forget") and returns nothing; it raises a 409 Conflict +# if a batch is already being created for the same customer. +client.batch_create_customer_memories( + customer_id="cust_01ham6bzcdeja9xzqhjf6daq30", + memories=[ + CustomerMemory( + external_id="order_456", + custom_type="order", + created_at=datetime(2026, 1, 2, 10, 0, 0), + data={"order_id": "order-456", "total": 42.0}, + ), + CustomerMemory( + external_id="pref_email", + custom_type="preference", + created_at=datetime(2026, 1, 3, 9, 30, 0), + data={"channel": "email"}, + ), + ], ) +logging.info("✅ Customer memories batch accepted") diff --git a/src/gradient_labs/_conversation_memories_upload.py b/src/gradient_labs/_conversation_memories_upload.py deleted file mode 100644 index 468a479..0000000 --- a/src/gradient_labs/_conversation_memories_upload.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import Optional, List, Dict, Any - -from dataclasses import dataclass -from dataclasses_json import dataclass_json - -from ._http_client import HttpClient - - -@dataclass_json -@dataclass(frozen=True) -class BulkUploadMemoriesParams: - # idempotency_key de-duplicates retries of the same upload. Re-uploading with - # the same key returns the original upload instead of inserting again. - idempotency_key: str - - # memories is the list of individual memories to store. Each element is an - # arbitrary JSON object stored verbatim as the memory's raw payload. - memories: List[Dict[str, Any]] - - # created_at_keys optionally lists JSON keys tried in order to read each - # memory's timestamp from its payload. When none match, the upload time is used. - created_at_keys: Optional[List[str]] = None - - -@dataclass_json -@dataclass(frozen=True) -class BulkUploadMemoriesResponse: - # upload_id identifies the upload that stored these memories. - upload_id: str - - # memories_inserted is the number of memories that were stored. - memories_inserted: int - - -def bulk_upload_conversation_memories( - *, client: HttpClient, conversation_id: str, params: BulkUploadMemoriesParams -) -> BulkUploadMemoriesResponse: - """bulk_upload_conversation_memories stores a batch of memories scoped to a - conversation, for the AI agent to search over on demand.""" - body: Dict[str, Any] = { - "idempotency_key": params.idempotency_key, - "memories": params.memories, - } - if params.created_at_keys is not None: - body["created_at_keys"] = params.created_at_keys - - rsp = client.post( - path=f"conversations/{conversation_id}/memories", - body=body, - ) - return BulkUploadMemoriesResponse.from_dict(rsp) diff --git a/src/gradient_labs/_customer_memories_batch_create.py b/src/gradient_labs/_customer_memories_batch_create.py new file mode 100644 index 0000000..16a0aec --- /dev/null +++ b/src/gradient_labs/_customer_memories_batch_create.py @@ -0,0 +1,59 @@ +from typing import Optional, List, Dict, Any +from datetime import datetime + +from dataclasses import dataclass, field +from dataclasses_json import dataclass_json, config +from marshmallow import fields + +from ._http_client import HttpClient + + +@dataclass_json +@dataclass(frozen=True) +class CustomerMemory: + # external_id is your own identifier for this memory. + external_id: str + + # created_at is when the event this memory describes occurred. + created_at: datetime = field( + metadata=config( + encoder=datetime.isoformat, + decoder=datetime.fromisoformat, + mm_field=fields.DateTime(format="iso"), + ) + ) + + # data is an arbitrary JSON object stored verbatim as the memory's payload. + data: Dict[str, Any] + + # custom_type is an optional free-form label for the memory. + custom_type: Optional[str] = None + + +def batch_create_customer_memories( + *, client: HttpClient, customer_id: str, memories: List[CustomerMemory] +) -> None: + """batch_create_customer_memories stores a batch of memories scoped to a + customer, for the AI agent to search over on demand. + + Each memory's data is stored verbatim as an arbitrary JSON object. The call + is asynchronous: it returns as soon as the batch is accepted. It returns a + 409 Conflict if a batch is already being created for the same customer.""" + body: Dict[str, Any] = { + "memories": [_memory_to_dict(memory) for memory in memories], + } + _ = client.post( + path=f"customers/{customer_id}/memories", + body=body, + ) + + +def _memory_to_dict(memory: CustomerMemory) -> Dict[str, Any]: + item: Dict[str, Any] = { + "external_id": memory.external_id, + "created_at": HttpClient.localize(memory.created_at), + "data": memory.data, + } + if memory.custom_type is not None: + item["custom_type"] = memory.custom_type + return item diff --git a/src/gradient_labs/client.py b/src/gradient_labs/client.py index 0696121..7b18d39 100644 --- a/src/gradient_labs/client.py +++ b/src/gradient_labs/client.py @@ -30,10 +30,9 @@ return_async_tool_result, ReturnAsyncToolResultParams, ) -from ._conversation_memories_upload import ( - bulk_upload_conversation_memories, - BulkUploadMemoriesParams, - BulkUploadMemoriesResponse, +from ._customer_memories_batch_create import ( + batch_create_customer_memories, + CustomerMemory, ) from ._outbound_conversation_start import ( @@ -400,22 +399,23 @@ def return_async_tool_result( params=params, ) - def bulk_upload_conversation_memories( + def batch_create_customer_memories( self, *, - conversation_id: str, - params: BulkUploadMemoriesParams, - ) -> BulkUploadMemoriesResponse: - """bulk_upload_conversation_memories stores a batch of memories scoped to a - conversation, for the AI agent to search over on demand. + customer_id: str, + memories: List[CustomerMemory], + ) -> None: + """batch_create_customer_memories stores a batch of memories scoped to a + customer, for the AI agent to search over on demand. - Each memory is stored verbatim as an arbitrary JSON object. Re-uploading - with the same idempotency_key returns the original upload instead of - inserting again.""" - return bulk_upload_conversation_memories( + Each memory's data is stored verbatim as an arbitrary JSON object. The + call is asynchronous: it returns as soon as the batch is accepted, and + does not return an upload id or inserted count. It returns a 409 Conflict + if a batch is already being created for the same customer.""" + batch_create_customer_memories( client=self.http_client, - conversation_id=conversation_id, - params=params, + customer_id=customer_id, + memories=memories, ) def upsert_hand_off_target(self, *, params: UpsertHandOffTargetParams) -> None: diff --git a/tests/test_conversation_memories_upload.py b/tests/test_conversation_memories_upload.py deleted file mode 100644 index a361bb6..0000000 --- a/tests/test_conversation_memories_upload.py +++ /dev/null @@ -1,55 +0,0 @@ -from unittest.mock import MagicMock - -from gradient_labs import Client, BulkUploadMemoriesParams - -CONVERSATION_ID = "conv_01ham6bzcdeja9xzqhjf6daq30" - - -def _client(response): - client = Client(api_key="test-key") - post = MagicMock(return_value=response) - client.http_client.post = post - return client, post - - -def test_bulk_upload_conversation_memories(): - client, post = _client( - {"upload_id": "upl_123", "memories_inserted": 2}, - ) - - rsp = client.bulk_upload_conversation_memories( - conversation_id=CONVERSATION_ID, - params=BulkUploadMemoriesParams( - idempotency_key="key-1", - memories=[{"note": "prefers email"}, {"tier": "premium"}], - ), - ) - - _, kwargs = post.call_args - assert kwargs["path"] == f"conversations/{CONVERSATION_ID}/memories" - body = kwargs["body"] - assert body["idempotency_key"] == "key-1" - assert body["memories"] == [{"note": "prefers email"}, {"tier": "premium"}] - assert "created_at_keys" not in body - - assert rsp.upload_id == "upl_123" - assert rsp.memories_inserted == 2 - - -def test_bulk_upload_conversation_memories_includes_created_at_keys(): - client, post = _client( - {"upload_id": "upl_456", "memories_inserted": 1}, - ) - - client.bulk_upload_conversation_memories( - conversation_id=CONVERSATION_ID, - params=BulkUploadMemoriesParams( - idempotency_key="key-2", - memories=[{"event": "signup"}], - created_at_keys=["occurred_at", "created_at"], - ), - ) - - _, kwargs = post.call_args - body = kwargs["body"] - assert body["created_at_keys"] == ["occurred_at", "created_at"] diff --git a/tests/test_customer_memories_batch_create.py b/tests/test_customer_memories_batch_create.py new file mode 100644 index 0000000..83aef6a --- /dev/null +++ b/tests/test_customer_memories_batch_create.py @@ -0,0 +1,68 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from gradient_labs import Client, CustomerMemory + +CUSTOMER_ID = "cust_01ham6bzcdeja9xzqhjf6daq30" + + +def _client(response=None): + client = Client(api_key="test-key") + post = MagicMock(return_value=response) + client.http_client.post = post + return client, post + + +def test_batch_create_customer_memories(): + client, post = _client() + + rsp = client.batch_create_customer_memories( + customer_id=CUSTOMER_ID, + memories=[ + CustomerMemory( + external_id="order_123", + custom_type="order", + created_at=datetime(2026, 7, 1, 10, 0, 0), + data={"total": 42.0}, + ), + CustomerMemory( + external_id="pref_1", + created_at=datetime(2026, 7, 2, 11, 30, 0), + data={"channel": "email"}, + ), + ], + ) + + assert rsp is None + + _, kwargs = post.call_args + assert kwargs["path"] == f"customers/{CUSTOMER_ID}/memories" + memories = kwargs["body"]["memories"] + assert len(memories) == 2 + + assert memories[0]["external_id"] == "order_123" + assert memories[0]["custom_type"] == "order" + assert memories[0]["created_at"] == "2026-07-01T10:00:00.000000Z" + assert memories[0]["data"] == {"total": 42.0} + + +def test_batch_create_customer_memories_omits_optional_custom_type(): + client, post = _client() + + client.batch_create_customer_memories( + customer_id=CUSTOMER_ID, + memories=[ + CustomerMemory( + external_id="pref_1", + created_at=datetime(2026, 7, 2, 11, 30, 0), + data={"channel": "email"}, + ), + ], + ) + + _, kwargs = post.call_args + memory = kwargs["body"]["memories"][0] + assert memory["external_id"] == "pref_1" + assert memory["created_at"] == "2026-07-02T11:30:00.000000Z" + assert memory["data"] == {"channel": "email"} + assert "custom_type" not in memory