diff --git a/docs/openapi.json b/docs/openapi.json index e5d6b55ce..02c0eb562 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -8175,18 +8175,63 @@ "properties": { "enabled": { "type": "boolean", - "description": "Whether profile generation runs for this project." + "description": "Whether profile generation runs for this project. Project-wide." }, - "schema": { + "entities": { "type": "object", - "additionalProperties": true, - "nullable": true, - "description": "JSON Schema describing the profile. Every property needs a description." + "description": "Per-entity-type settings. Only `user` is available in this release.", + "properties": { + "user": { + "type": "object", + "properties": { + "schema": { + "type": "object", + "additionalProperties": true, + "nullable": true, + "description": "JSON Schema describing the profile. Every property needs a description." + }, + "custom_instructions": { + "type": "string", + "nullable": true, + "description": "Extra guidance for the extraction step." + } + } + }, + "agent": { + "type": "object", + "properties": { + "schema": { + "type": "object", + "additionalProperties": true, + "nullable": true, + "description": "JSON Schema describing the profile. Every property needs a description." + }, + "custom_instructions": { + "type": "string", + "nullable": true, + "description": "Extra guidance for the extraction step." + } + } + } + } }, - "custom_instructions": { - "type": "string", - "nullable": true, - "description": "Extra guidance for the extraction step." + "capabilities": { + "type": "object", + "properties": { + "jobs": { + "type": "boolean" + }, + "estimates": { + "type": "boolean" + }, + "samples": { + "type": "boolean" + }, + "full_rebuild": { + "type": "boolean", + "description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false." + } + } } } } @@ -8208,21 +8253,33 @@ "application/json": { "schema": { "type": "object", + "description": "Only the fields present are written. `schema` and `custom_instructions` nest under `entities.user`; a flat body is rejected.", "properties": { "enabled": { "type": "boolean", - "description": "Whether profile generation runs for this project." + "description": "Whether profile generation runs for this project. Project-wide." }, - "schema": { + "entities": { "type": "object", - "additionalProperties": true, - "nullable": true, - "description": "JSON Schema describing the profile. Every property needs a description." - }, - "custom_instructions": { - "type": "string", - "nullable": true, - "description": "Extra guidance for the extraction step." + "description": "Per-entity-type settings. Only `user` is available in this release.", + "properties": { + "user": { + "type": "object", + "properties": { + "schema": { + "type": "object", + "additionalProperties": true, + "nullable": true, + "description": "JSON Schema describing the profile. Every property needs a description. Send null to clear it." + }, + "custom_instructions": { + "type": "string", + "nullable": true, + "description": "Extra guidance for the extraction step. Send null to clear it." + } + } + } + } } } } @@ -8239,18 +8296,63 @@ "properties": { "enabled": { "type": "boolean", - "description": "Whether profile generation runs for this project." + "description": "Whether profile generation runs for this project. Project-wide." }, - "schema": { + "entities": { "type": "object", - "additionalProperties": true, - "nullable": true, - "description": "JSON Schema describing the profile. Every property needs a description." + "description": "Per-entity-type settings. Only `user` is available in this release.", + "properties": { + "user": { + "type": "object", + "properties": { + "schema": { + "type": "object", + "additionalProperties": true, + "nullable": true, + "description": "JSON Schema describing the profile. Every property needs a description." + }, + "custom_instructions": { + "type": "string", + "nullable": true, + "description": "Extra guidance for the extraction step." + } + } + }, + "agent": { + "type": "object", + "properties": { + "schema": { + "type": "object", + "additionalProperties": true, + "nullable": true, + "description": "JSON Schema describing the profile. Every property needs a description." + }, + "custom_instructions": { + "type": "string", + "nullable": true, + "description": "Extra guidance for the extraction step." + } + } + } + } }, - "custom_instructions": { - "type": "string", - "nullable": true, - "description": "Extra guidance for the extraction step." + "capabilities": { + "type": "object", + "properties": { + "jobs": { + "type": "boolean" + }, + "estimates": { + "type": "boolean" + }, + "samples": { + "type": "boolean" + }, + "full_rebuild": { + "type": "boolean", + "description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false." + } + } } } } @@ -8291,7 +8393,8 @@ "schema": { "type": "object", "required": [ - "operation" + "operation", + "entity_type" ], "properties": { "operation": { @@ -8306,8 +8409,7 @@ "type": "string", "enum": [ "user" - ], - "default": "user" + ] }, "entity_id": { "type": "string", diff --git a/docs/platform/features/user-profiles.mdx b/docs/platform/features/user-profiles.mdx index d88777604..23aab18e8 100644 --- a/docs/platform/features/user-profiles.mdx +++ b/docs/platform/features/user-profiles.mdx @@ -182,27 +182,48 @@ Sampling is asynchronous: the call returns a job as soon as it is queued. Poll ` ```python Python +import time + job = client.sample_profiles(limit=5) -# Poll until the sample job finishes. -status = client.get_profile_job(job["status_url"])["job"] +# Poll until the sample job reaches a terminal state (job status is UPPERCASE). +TERMINAL = {"SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"} +deadline = time.time() + 120 +while True: + status = client.get_profile_job(job["status_url"])["job"] + if status["status"] in TERMINAL: + break + if time.time() > deadline: + raise TimeoutError("Sample job did not finish in time") + time.sleep(3) + print(status["status"], status["succeeded"], "of", status["total"]) -# Each result names one sampled entity; read its saved profile. -for row in status["results"]: - print(client.get_profile(row["entity_id"])) +# The create response lists the sampled entities; read each one's saved profile. +for entity_id in job["entity_ids"]: + print(client.get_profile(entity_id)) ``` ```typescript TypeScript const job = await client.sampleProfiles({ limit: 5 }); -// Poll until the sample job finishes. -const { job: status } = await client.getProfileJob(job.statusUrl); +// Poll until the sample job reaches a terminal state (job status is UPPERCASE). +const TERMINAL = ["SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"]; +const deadline = Date.now() + 120_000; +let status; +while (true) { + status = (await client.getProfileJob(job.statusUrl)).job; + if (TERMINAL.includes(status.status)) break; + if (Date.now() > deadline) + throw new Error("Sample job did not finish in time"); + await new Promise((resolve) => setTimeout(resolve, 3000)); +} + console.log(status.status, status.succeeded, "of", status.total); -// Each result names one sampled entity; read its saved profile. -for (const row of status.results ?? []) { - console.log(await client.getProfile({ entityId: row.entityId })); +// The create response lists the sampled entities; read each one's saved profile. +for (const entityId of job.entityIds ?? []) { + console.log(await client.getProfile({ entityId })); } ``` diff --git a/examples/notebooks/user-profiles.ipynb b/examples/notebooks/user-profiles.ipynb index 07b4acc3c..8a237aae4 100644 --- a/examples/notebooks/user-profiles.ipynb +++ b/examples/notebooks/user-profiles.ipynb @@ -39,30 +39,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "import json\n", - "import os\n", - "import time\n", - "import uuid\n", - "\n", - "import mem0\n", - "from mem0 import MemoryClient\n", - "\n", - "API_KEY = os.environ.get(\"MEM0_API_KEY\")\n", - "if not API_KEY:\n", - " import getpass\n", - "\n", - " API_KEY = getpass.getpass(\"API key: \")\n", - "\n", - "client = MemoryClient(api_key=API_KEY, host=os.environ.get(\"MEM0_API_HOST\") or None)\n", - "\n", - "# Fresh id each run, so nothing below is stale from a previous pass.\n", - "USER_ID = f\"demo_{uuid.uuid4().hex[:8]}\"\n", - "\n", - "print(\"sdk :\", mem0.__file__) # must be this worktree\n", - "print(\"host :\", client.host)\n", - "print(\"demo user:\", USER_ID)\n" - ] + "source": "import json\nimport os\nimport time\nimport uuid\n\nimport mem0\nfrom mem0 import MemoryClient\n\nAPI_KEY = os.environ.get(\"MEM0_API_KEY\")\nif not API_KEY:\n import getpass\n\n API_KEY = getpass.getpass(\"API key: \")\n\nclient = MemoryClient(api_key=API_KEY, host=os.environ.get(\"MEM0_API_HOST\") or None)\n\n# Fresh id each run, so nothing below is stale from a previous pass.\nUSER_ID = f\"demo_{uuid.uuid4().hex[:8]}\"\n\n# Snapshot the project's profile settings up front. This notebook overwrites the\n# shared project schema/instructions/enabled below; the cleanup cell restores this.\nORIGINAL_SETTINGS = client.get_profile_settings()\n\nprint(\"sdk :\", mem0.__file__) # must be this worktree\nprint(\"host :\", client.host)\nprint(\"demo user:\", USER_ID)" }, { "cell_type": "markdown", @@ -527,43 +504,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "JOB_TERMINAL = {\"SUCCEEDED\", \"FAILED\", \"COMPLETED\", \"CANCELLED\"}\n", - "\n", - "\n", - "def wait_for_job(job_response, timeout=300, interval=5):\n", - " \"\"\"Poll a generation job. Prefer status_url over a bare job id, so a route\n", - " change needs no client update.\"\"\"\n", - " handle = job_response.get(\"status_url\") or job_response[\"job_id\"]\n", - " deadline = time.time() + timeout\n", - " status = None\n", - " while time.time() < deadline:\n", - " status = client.get_profile_job(handle)[\"job\"]\n", - " total = status.get(\"total\")\n", - " print(\n", - " f\" {status['status']} \"\n", - " f\"completed={status.get('completed', 0)}/{total if total is not None else '?'} \"\n", - " f\"succeeded={status.get('succeeded', 0)} \"\n", - " f\"failed={status.get('failed', 0)} \"\n", - " f\"skipped={status.get('skipped', 0)}\"\n", - " )\n", - " if str(status.get(\"status\", \"\")).upper() in JOB_TERMINAL:\n", - " return status\n", - " time.sleep(interval)\n", - " return status\n", - "\n", - "\n", - "if job is None:\n", - " print(\"no sample job to poll\")\n", - "else:\n", - " final = wait_for_job(job)\n", - "\n", - " print(\"\\n--- what the sample produced ---\")\n", - " for entity_id in job.get(\"entity_ids\", []):\n", - " got = client.get_profile(entity_id)\n", - " print(f\"\\n{entity_id} [{got['status']}]\")\n", - " print(\" \", json.dumps(got[\"profile\"])[:220])\n" - ] + "source": "JOB_TERMINAL = {\"SUCCEEDED\", \"PARTIALLY_SUCCEEDED\", \"FAILED\", \"CANCELLED\"}\n\n\ndef wait_for_job(job_response, timeout=300, interval=5):\n \"\"\"Poll a generation job. Prefer status_url over a bare job id, so a route\n change needs no client update. Raise on timeout so an unfinished job is never\n mistaken for a finished one.\"\"\"\n handle = job_response.get(\"status_url\") or job_response[\"job_id\"]\n deadline = time.time() + timeout\n status = None\n while time.time() < deadline:\n status = client.get_profile_job(handle)[\"job\"]\n total = status.get(\"total\")\n print(\n f\" {status['status']} \"\n f\"completed={status.get('completed', 0)}/{total if total is not None else '?'} \"\n f\"succeeded={status.get('succeeded', 0)} \"\n f\"failed={status.get('failed', 0)} \"\n f\"skipped={status.get('skipped', 0)}\"\n )\n if str(status.get(\"status\", \"\")).upper() in JOB_TERMINAL:\n return status\n time.sleep(interval)\n raise TimeoutError(\n f\"job not terminal in {timeout}s (last status: {status.get('status') if status else 'none'})\"\n )\n\n\nif job is None:\n print(\"no sample job to poll\")\nelse:\n final = wait_for_job(job)\n\n print(\"\\n--- what the sample produced ---\")\n for entity_id in job.get(\"entity_ids\", []):\n got = client.get_profile(entity_id)\n print(f\"\\n{entity_id} [{got['status']}]\")\n print(\" \", json.dumps(got[\"profile\"])[:220])" }, { "cell_type": "markdown", @@ -735,13 +676,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "for entity_id in (USER_ID, fresh):\n", - " r = client.client.delete(f\"/v2/entities/user/{entity_id}/\")\n", - " print(entity_id, \"->\", r.status_code)\n", - "\n", - "# Leave the project's own profile settings alone — other people may share this env.\n" - ] + "source": "for entity_id in (USER_ID, fresh):\n r = client.client.delete(f\"/v2/entities/user/{entity_id}/\")\n print(entity_id, \"->\", r.status_code)\n\n# Restore the project's profile settings to the start-of-run snapshot, so a shared\n# env is left exactly as we found it. Passing the original values (including None)\n# clears anything this notebook set — the SDK distinguishes an explicit None from an\n# omitted argument.\n_user = ORIGINAL_SETTINGS.get(\"entities\", {}).get(\"user\", {})\nclient.update_profile_settings(\n enabled=ORIGINAL_SETTINGS.get(\"enabled\", False),\n schema=_user.get(\"schema\"),\n custom_instructions=_user.get(\"custom_instructions\"),\n)\nprint(\"profile settings restored to the pre-notebook snapshot\")" }, { "cell_type": "markdown", @@ -805,4 +740,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/mem0-ts/src/client/index.ts b/mem0-ts/src/client/index.ts index 2eef9cd9b..e27eccc9c 100644 --- a/mem0-ts/src/client/index.ts +++ b/mem0-ts/src/client/index.ts @@ -26,12 +26,12 @@ export type { ProfileEntityType, ProfileStatus, ProfileResponse, - ProfileTriggerResponse, + ProfileJobResponse, + ProfileJobStatus, ProfileSettings, ProfileSettingsResponse, EntityProfileSettings, ProfileSampleResult, - ProfileSamplesResponse, } from "./mem0.types"; // Re-export enums as values (not type-only) diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 2116b8ec9..3ef8f8a71 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { v4 as uuidv4 } from "uuid"; import { AllUsers, PaginatedMemories, @@ -24,10 +25,8 @@ import { ProfileResponse, ProfileJobResponse, ProfileJobStatus, - ProfileTriggerResponse, ProfileSettings, ProfileSettingsResponse, - ProfileSamplesResponse, } from "./mem0.types"; import { captureClientEvent, @@ -795,10 +794,14 @@ export default class MemoryClient { * Profiles are otherwise built once a user crosses an internal message * threshold, so a new user has none for its first few memories. Returns as * soon as the work is queued: poll {@link getProfile} and branch on `status`. + * + * Pass `idempotencyKey` and reuse it to retry a lost request without starting + * (and being billed for) a second job. */ async generateProfile(data: { entityId: string; - }): Promise { + idempotencyKey?: string; + }): Promise { this._captureEvent("generate_profile", []); await this._awaitIdentity(); @@ -806,7 +809,10 @@ export default class MemoryClient { `${this.host}${PROFILE_JOBS_PATH}`, { method: "POST", - headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() }, + headers: { + ...this.headers, + "Idempotency-Key": data.idempotencyKey ?? uuidv4(), + }, body: JSON.stringify({ operation: "trigger", entity_type: "user", @@ -911,7 +917,10 @@ export default class MemoryClient { * Real generations against real memories, and the results are kept: the * profiles are written to those users and count toward usage. */ - async sampleProfiles(data?: { limit?: number }): Promise { + async sampleProfiles(data?: { + limit?: number; + idempotencyKey?: string; + }): Promise { this._captureEvent("sample_profiles", []); await this._awaitIdentity(); @@ -919,7 +928,10 @@ export default class MemoryClient { `${this.host}${PROFILE_JOBS_PATH}`, { method: "POST", - headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() }, + headers: { + ...this.headers, + "Idempotency-Key": data?.idempotencyKey ?? uuidv4(), + }, body: JSON.stringify({ operation: "sample", // Required: the API refuses a job that does not name an entity kind. diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index ce9a13734..631602eef 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -263,6 +263,8 @@ export interface ProfileResponse { entityId: string; updatedAt: string | null; generationCount: number; + /** The last generation's failure reason, when `status` is `failed`. */ + error?: string | null; } /** Every accepted generation. `statusUrl` is the server's own poll path. */ @@ -272,20 +274,16 @@ export interface ProfileJobResponse { statusUrl: string; operation: string; entityType: ProfileEntityType; - usageUnits?: number; + /** Entities reserved against usage for this job. */ + entityCountReserved?: number; eventId?: string; replayed?: boolean; /** Sample runs only: how many entities were picked. */ sampled?: number; /** Sample runs only: the entity ids picked. Read each one with `getProfile`. */ entityIds?: string[]; - /** @deprecated The API returns `entityIds`; this is never populated. */ - results?: Array; } -/** @deprecated Use {@link ProfileJobResponse}. */ -export type ProfileTriggerResponse = ProfileJobResponse; - /** The settings to write. `schema` and `customInstructions` apply to user profiles. */ export interface ProfileSettings { /** Turn profile generation on or off. Project-wide. */ @@ -321,9 +319,6 @@ export interface ProfileSampleResult { [key: string]: any; } -/** @deprecated Use {@link ProfileJobResponse}. */ -export type ProfileSamplesResponse = ProfileJobResponse; - /** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */ export interface ProfileJobStatus { job: { diff --git a/mem0-ts/src/client/tests/memoryClient.profiles.test.ts b/mem0-ts/src/client/tests/memoryClient.profiles.test.ts index 96decf113..67bf10f71 100644 --- a/mem0-ts/src/client/tests/memoryClient.profiles.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.profiles.test.ts @@ -98,11 +98,14 @@ describe("MemoryClient - generateProfile()", () => { extra.set("/v2/profiles/jobs/", { status: 202, body: { - message: "Profile generation started.", + job_id: "01a0ceb6-97c5-7cb2-80c4-b81a97876ef1", + status: "QUEUED", + status_url: "/v2/profiles/jobs/01a0ceb6-97c5-7cb2-80c4-b81a97876ef1/", + operation: "trigger", entity_type: "user", - entity_id: "alice", - profile_id: "p_1", - status: "PENDING", + entity_count_reserved: 1, + event_id: "01a0ceb6-9865-7841-9b25-d280511382ff", + replayed: false, }, }); const mock = setupMockFetch(extra); @@ -116,7 +119,39 @@ describe("MemoryClient - generateProfile()", () => { expect(body.operation).toBe("trigger"); expect(body.entity_type).toBe("user"); expect(body.entity_id).toBe("alice"); - expect(result.profileId).toBe("p_1"); + // A generated Idempotency-Key is always sent so the create can be retried safely. + const headers = call![1].headers as Record; + expect(headers["Idempotency-Key"]).toBeTruthy(); + expect(result.jobId).toBe("01a0ceb6-97c5-7cb2-80c4-b81a97876ef1"); + expect(result.statusUrl).toBe( + "/v2/profiles/jobs/01a0ceb6-97c5-7cb2-80c4-b81a97876ef1/", + ); + expect(result.replayed).toBe(false); + }); + + test("reuses a caller-supplied idempotency key", async () => { + const extra = new Map(); + extra.set("/v2/profiles/jobs/", { + status: 202, + body: { + job_id: "j1", + status: "QUEUED", + status_url: "/v2/profiles/jobs/j1/", + operation: "trigger", + entity_type: "user", + }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.generateProfile({ + entityId: "alice", + idempotencyKey: "retry-key-123", + }); + + const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST"); + const headers = call![1].headers as Record; + expect(headers["Idempotency-Key"]).toBe("retry-key-123"); }); }); diff --git a/mem0/client/main.py b/mem0/client/main.py index 4863df3ad..d354c4892 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -44,11 +44,14 @@ ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"}) PROFILE_JOBS_PATH = "/v2/profiles/jobs/" PROFILE_SETTINGS_PATH = "/v2/profiles/settings/" +# Distinguishes an omitted argument from an explicit ``None`` that clears a field. +_UNSET: Any = object() + def _profile_settings_payload( enabled: Optional[bool], - schema: Optional[Dict[str, Any]], - custom_instructions: Optional[str], + schema: Any = _UNSET, + custom_instructions: Any = _UNSET, ) -> Dict[str, Any]: """Build the settings body the API accepts. @@ -57,7 +60,7 @@ def _profile_settings_payload( ``get_profile_settings`` returns, so the two round-trip. Sending them flat is rejected with ``Unsupported settings``, so this shape is - not cosmetic. + not cosmetic. ``_UNSET`` leaves a field unchanged; an explicit ``None`` clears it. """ payload: Dict[str, Any] = {} @@ -65,9 +68,9 @@ def _profile_settings_payload( payload["enabled"] = enabled entity_settings: Dict[str, Any] = {} - if schema is not None: + if schema is not _UNSET: entity_settings["schema"] = schema - if custom_instructions is not None: + if custom_instructions is not _UNSET: entity_settings["custom_instructions"] = custom_instructions if entity_settings: @@ -406,7 +409,9 @@ class MemoryClient: payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"} if not payload: - raise ValueError("At least one of text, metadata, timestamp, or expiration_date must be provided for update.") + raise ValueError( + "At least one of text, metadata, timestamp, or expiration_date must be provided for update." + ) capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "sync"}) params = self._prepare_params() @@ -743,7 +748,7 @@ class MemoryClient: return response.json() @api_error_handler - def generate_profile(self, entity_id: str) -> Dict[str, Any]: + def generate_profile(self, entity_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]: """Generate or refresh the profile for a single user, now. Profiles are otherwise built once a user crosses an internal message @@ -752,10 +757,15 @@ class MemoryClient: Args: entity_id: The user's id, as you supplied it on ``add``. + idempotency_key: Optional key that makes the create idempotent. Reuse + the same value to safely retry a lost request without starting + (and being billed for) a second job. A fresh key is generated when + omitted. Returns: - Dict containing ``profile_id``, ``entity_type``, ``entity_id`` and - ``status``. + Dict containing ``job_id``, ``status``, ``status_url``, ``operation``, + ``entity_type``, ``entity_count_reserved``, ``event_id`` and + ``replayed``. Poll :meth:`get_profile` and branch on ``status``. Raises: ValidationError: If profiles are not enabled and configured for the @@ -766,7 +776,7 @@ class MemoryClient: response = self.client.post( PROFILE_JOBS_PATH, json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id}, - headers={"Idempotency-Key": uuid.uuid4().hex}, + headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex}, ) response.raise_for_status() capture_client_event("client.generate_profile", self, {"sync_type": "sync"}) @@ -791,8 +801,8 @@ class MemoryClient: def update_profile_settings( self, enabled: Optional[bool] = None, - schema: Optional[Dict[str, Any]] = None, - custom_instructions: Optional[str] = None, + schema: Any = _UNSET, + custom_instructions: Any = _UNSET, ) -> Dict[str, Any]: """Update the profile settings for the current project. @@ -801,9 +811,11 @@ class MemoryClient: Args: enabled: Turn profile generation on or off. Project-wide. schema: JSON Schema for the profile. Every property needs a - ``description``. Applies to user profiles. - custom_instructions: Extra guidance for the extraction step. - Applies to user profiles. + ``description``. Pass ``None`` to clear it; omit to leave it + unchanged. Applies to user profiles. + custom_instructions: Extra guidance for the extraction step. Pass + ``None`` to clear it; omit to leave it unchanged. Applies to user + profiles. Returns: Dict with the settings as stored after the update, in the same @@ -824,7 +836,7 @@ class MemoryClient: return response.json() @api_error_handler - def sample_profiles(self, limit: Optional[int] = None) -> Dict[str, Any]: + def sample_profiles(self, limit: Optional[int] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]: """Generate profiles for a few real users, to check a schema. Real generations against real memories, and the results are kept. The @@ -832,6 +844,9 @@ class MemoryClient: Args: limit: How many users to sample, 1-10. Defaults to the server value. + idempotency_key: Optional key that makes the create idempotent. Reuse + the same value to safely retry without starting a second sample + run. A fresh key is generated when omitted. Returns: Dict containing ``job_id``, ``status``, ``status_url``, ``sampled`` @@ -849,7 +864,7 @@ class MemoryClient: response = self.client.post( PROFILE_JOBS_PATH, json=payload, - headers={"Idempotency-Key": uuid.uuid4().hex}, + headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex}, ) response.raise_for_status() capture_client_event("client.sample_profiles", self, {"sync_type": "sync"}) @@ -1484,7 +1499,9 @@ class AsyncMemoryClient: payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"} if not payload: - raise ValueError("At least one of text, metadata, timestamp, or expiration_date must be provided for update.") + raise ValueError( + "At least one of text, metadata, timestamp, or expiration_date must be provided for update." + ) capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "async"}) params = self._prepare_params() @@ -1807,7 +1824,7 @@ class AsyncMemoryClient: return response.json() @api_error_handler - async def generate_profile(self, entity_id: str) -> Dict[str, Any]: + async def generate_profile(self, entity_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]: """Generate or refresh the profile for a single user, now. Profiles are otherwise built once a user crosses an internal message @@ -1816,10 +1833,15 @@ class AsyncMemoryClient: Args: entity_id: The user's id, as you supplied it on ``add``. + idempotency_key: Optional key that makes the create idempotent. Reuse + the same value to safely retry a lost request without starting + (and being billed for) a second job. A fresh key is generated when + omitted. Returns: - Dict containing ``profile_id``, ``entity_type``, ``entity_id`` and - ``status``. + Dict containing ``job_id``, ``status``, ``status_url``, ``operation``, + ``entity_type``, ``entity_count_reserved``, ``event_id`` and + ``replayed``. Poll :meth:`get_profile` and branch on ``status``. Raises: ValidationError: If profiles are not enabled and configured for the @@ -1830,7 +1852,7 @@ class AsyncMemoryClient: response = await self.async_client.post( PROFILE_JOBS_PATH, json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id}, - headers={"Idempotency-Key": uuid.uuid4().hex}, + headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex}, ) response.raise_for_status() capture_client_event("client.generate_profile", self, {"sync_type": "async"}) @@ -1855,8 +1877,8 @@ class AsyncMemoryClient: async def update_profile_settings( self, enabled: Optional[bool] = None, - schema: Optional[Dict[str, Any]] = None, - custom_instructions: Optional[str] = None, + schema: Any = _UNSET, + custom_instructions: Any = _UNSET, ) -> Dict[str, Any]: """Update the profile settings for the current project. @@ -1865,9 +1887,11 @@ class AsyncMemoryClient: Args: enabled: Turn profile generation on or off. Project-wide. schema: JSON Schema for the profile. Every property needs a - ``description``. Applies to user profiles. - custom_instructions: Extra guidance for the extraction step. - Applies to user profiles. + ``description``. Pass ``None`` to clear it; omit to leave it + unchanged. Applies to user profiles. + custom_instructions: Extra guidance for the extraction step. Pass + ``None`` to clear it; omit to leave it unchanged. Applies to user + profiles. Returns: Dict with the settings as stored after the update, in the same @@ -1888,7 +1912,9 @@ class AsyncMemoryClient: return response.json() @api_error_handler - async def sample_profiles(self, limit: Optional[int] = None) -> Dict[str, Any]: + async def sample_profiles( + self, limit: Optional[int] = None, idempotency_key: Optional[str] = None + ) -> Dict[str, Any]: """Generate profiles for a few real users, to check a schema. Real generations against real memories, and the results are kept. The @@ -1896,6 +1922,9 @@ class AsyncMemoryClient: Args: limit: How many users to sample, 1-10. Defaults to the server value. + idempotency_key: Optional key that makes the create idempotent. Reuse + the same value to safely retry without starting a second sample + run. A fresh key is generated when omitted. Returns: Dict containing ``job_id``, ``status``, ``status_url``, ``sampled`` @@ -1913,7 +1942,7 @@ class AsyncMemoryClient: response = await self.async_client.post( PROFILE_JOBS_PATH, json=payload, - headers={"Idempotency-Key": uuid.uuid4().hex}, + headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex}, ) response.raise_for_status() capture_client_event("client.sample_profiles", self, {"sync_type": "async"}) diff --git a/tests/test_client_profiles.py b/tests/test_client_profiles.py index ea0ae5850..c2ea0eff3 100644 --- a/tests/test_client_profiles.py +++ b/tests/test_client_profiles.py @@ -82,7 +82,9 @@ class TestGetProfile: class TestGenerateProfile: def test_posts_entity_type_and_id(self, mock_memory_client): - mock_memory_client.client.post.return_value = _mock_response({"profile_id": "p_1", "status": "PENDING"}) + mock_memory_client.client.post.return_value = _mock_response( + {"job_id": "j1", "status": "QUEUED", "status_url": "/v2/profiles/jobs/j1/"} + ) mock_memory_client.generate_profile("alice") @@ -91,6 +93,15 @@ class TestGenerateProfile: {"operation": "trigger", "entity_type": "user", "entity_id": "alice"}, ) + def test_reuses_caller_idempotency_key(self, mock_memory_client): + """A caller-supplied key lets a retry hit the same job instead of billing twice.""" + mock_memory_client.client.post.return_value = _mock_response({"job_id": "j1", "status": "QUEUED"}) + + mock_memory_client.generate_profile("alice", idempotency_key="retry-key-123") + + _, kwargs = mock_memory_client.client.post.call_args + assert kwargs["headers"]["Idempotency-Key"] == "retry-key-123" + class TestProfileSettings: def test_get_reads_v2(self, mock_memory_client): @@ -160,6 +171,15 @@ class TestProfileSettings: }, ) + def test_update_clears_fields_with_explicit_none(self, mock_memory_client): + """Explicit ``None`` clears a field; the sentinel default leaves it untouched.""" + mock_memory_client.client.post.return_value = _mock_response({"enabled": True}) + + mock_memory_client.update_profile_settings(schema=None, custom_instructions=None) + + _, kwargs = mock_memory_client.client.post.call_args + assert kwargs["json"] == {"entities": {"user": {"schema": None, "custom_instructions": None}}} + class TestSampleProfiles: def test_sample_without_limit(self, mock_memory_client): @@ -210,7 +230,7 @@ class TestAsyncClientParity: async_client.async_client.get.assert_called_once_with("/v2/entities/user/alice/profile/") def test_generate_profile(self, async_client): - async_client.async_client.post = AsyncMock(return_value=_mock_response({"profile_id": "p_1"})) + async_client.async_client.post = AsyncMock(return_value=_mock_response({"job_id": "j1", "status": "QUEUED"})) asyncio.run(async_client.generate_profile("alice")) @@ -240,3 +260,13 @@ class TestAsyncClientParity: "/v2/profiles/settings/", json={"enabled": True, "entities": {"user": {"schema": schema}}}, ) + + def test_update_settings_clears_with_none(self, async_client): + async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True})) + + asyncio.run(async_client.update_profile_settings(custom_instructions=None)) + + async_client.async_client.post.assert_called_once_with( + "/v2/profiles/settings/", + json={"entities": {"user": {"custom_instructions": None}}}, + )