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/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" 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"]