diff --git a/docs/platform/features/user-profiles.mdx b/docs/platform/features/user-profiles.mdx index 793f745b3..6423b1d7d 100644 --- a/docs/platform/features/user-profiles.mdx +++ b/docs/platform/features/user-profiles.mdx @@ -276,11 +276,33 @@ On a project where profiles are turned off, a read returns `status: not_enabled` ## Settings reference -| Field | Type | Description | +| Argument | Type | Description | |---|---|---| | `enabled` | boolean | Whether profile generation runs for the project | | `schema` | object | JSON Schema describing the profile. Every property needs a `description` | | `custom_instructions` | string | Extra guidance applied during extraction | +| `entity_type` | string | Which entity kind `schema` and `custom_instructions` apply to. Defaults to `user` | + +`enabled` is project-wide. `schema` and `custom_instructions` belong to one +entity type, so the stored settings nest them under `entities`: + +```json +{ + "enabled": true, + "entities": { + "user": { + "schema": { "type": "object", "properties": { "...": {} } }, + "custom_instructions": "Prefer durable traits over one-off remarks." + }, + "agent": { "schema": null, "custom_instructions": null } + }, + "capabilities": { "full_rebuild": false } +} +``` + +That is what a read returns and what a write accepts. The SDKs take the fields +flat and nest them for you, so a schema you write with +`update_profile_settings` comes back unchanged from `get_profile_settings`. Profile settings are per project. An API key is scoped to one project, so profiles never cross a project boundary. diff --git a/examples/notebooks/user-profiles.ipynb b/examples/notebooks/user-profiles.ipynb new file mode 100644 index 000000000..2191e9416 --- /dev/null +++ b/examples/notebooks/user-profiles.ipynb @@ -0,0 +1,833 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# User Profiles — live demo\n", + "\n", + "A **profile** is a structured JSON document about ONE user, filled by an LLM from that\n", + "user's memories, shaped by a JSON Schema you supply.\n", + "\n", + "Search answers *\"what did this user say about X\"*. A profile answers *\"who is this\n", + "user\"*, in one read, with no query to write — and it is available on the first turn of a\n", + "session, before the user has said anything.\n", + "\n", + "**What this notebook does:** feed a user 12 conversation turns, watch a profile get\n", + "generated from them, add 6 more turns that contradict the first set, and watch the\n", + "profile rewrite itself. Then it shows every way the API says no.\n", + "\n", + "**You need:** an API key, and a project on the **Pro plan or higher**. Never commit one.\n", + "\n", + "> Set `MEM0_API_KEY`, and `MEM0_API_HOST` if you are pointing at a sandbox rather than\n", + "> production. The cells below read both from the environment.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# This notebook drives the SDK from this worktree, not the published mem0ai:\n", + "# the profile fixes below are not released yet.\n", + "%pip install -q -e ../..\n" + ] + }, + { + "cell_type": "code", + "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" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Define the schema\n", + "\n", + "The schema is handed to the model as a **tool definition**, and each field's\n", + "`description` is the only instruction the model gets about what belongs there. An\n", + "undescribed field is a field the model guesses at.\n", + "\n", + "Rules worth knowing:\n", + "\n", + "- root `type: object` with a **non-empty** `properties` — an empty one is refused, because\n", + " it would bill you to extract nothing\n", + "- the root keys `_profile_config_version` and `entities` are **reserved** and rejected:\n", + " they name the storage envelope, so a schema using them could not be read back\n", + " unambiguously\n", + "- keep it small. The whole schema is sent to the model on every generation\n", + "\n", + "Descriptions are **not** enforced on write in this build — a property without one is\n", + "accepted and then quietly underfilled at generation time. Section F1 demonstrates it.\n", + "Treat descriptions as your job, not the validator's.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "SCHEMA = {\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " \"occupation\": {\n", + " \"type\": \"string\",\n", + " \"description\": \"The person's current job title, in one short phrase.\",\n", + " },\n", + " \"location\": {\n", + " \"type\": \"string\",\n", + " \"description\": \"The city or region the person currently lives in.\",\n", + " },\n", + " \"interests\": {\n", + " \"type\": \"array\",\n", + " \"items\": {\"type\": \"string\"},\n", + " \"description\": \"Hobbies and topics they return to, as short lowercase tags.\",\n", + " },\n", + " \"dietary_restrictions\": {\n", + " \"type\": \"array\",\n", + " \"items\": {\"type\": \"string\"},\n", + " \"description\": \"Foods the person avoids, and why, if they said.\",\n", + " },\n", + " \"communication_style\": {\n", + " \"type\": \"string\",\n", + " \"enum\": [\"concise\", \"detailed\", \"casual\", \"formal\"],\n", + " \"description\": \"How this person prefers to be answered.\",\n", + " },\n", + " \"expertise_level\": {\n", + " \"type\": \"string\",\n", + " \"enum\": [\"beginner\", \"intermediate\", \"advanced\"],\n", + " \"description\": \"Their technical depth, judged from how they discuss their work.\",\n", + " },\n", + " },\n", + "}\n", + "\n", + "settings = client.update_profile_settings(\n", + " enabled=True,\n", + " schema=SCHEMA,\n", + " custom_instructions=(\n", + " \"Prefer facts the person stated outright over anything inferred. \"\n", + " \"Leave a field empty rather than guessing.\"\n", + " ),\n", + ")\n", + "\n", + "# Sorted, because JSONB storage does not preserve the key order you sent.\n", + "# Compare a stored schema by SET, never by string or by key order.\n", + "stored = settings[\"entities\"][\"user\"][\"schema\"]\n", + "print(\"schema fields:\", sorted(stored[\"properties\"]))\n", + "print(\"enabled :\", settings[\"enabled\"])\n", + "print(\"capabilities :\", settings[\"capabilities\"])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`enabled` is project-wide; `schema` and `custom_instructions` belong to one entity kind\n", + "and are stored under `entities`. The SDK takes them flat and nests them for you, so what\n", + "you write comes back unchanged from `get_profile_settings()`.\n", + "\n", + "Only the arguments you pass are written. To turn the feature off without touching your\n", + "schema, send `enabled` alone.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. The turns\n", + "\n", + "Twelve conversation turns for one user. Nothing about `add()` changes — profiles are a\n", + "side effect of the normal pipeline.\n", + "\n", + "Twelve, not five, because generation fires when an entity crosses a **10-message\n", + "boundary**. Below that it waits for a flush window measured in hours, and this notebook\n", + "would sit there.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "TURNS = [\n", + " (\"user\", \"Hey — I just moved to Berlin for a new job.\"),\n", + " (\"assistant\", \"Congratulations! What's the new role?\"),\n", + " (\"user\", \"Senior data engineer at a logistics company. Mostly Spark and Airflow.\"),\n", + " (\"assistant\", \"Nice stack. How are you finding the pipelines there?\"),\n", + " (\"user\", \"Honestly the DAGs are a mess. I've been rewriting the partitioning to cut shuffle.\"),\n", + " (\"assistant\", \"That usually pays off fast. Anything blocking you?\"),\n", + " (\"user\", \"Just time. Keep it short when you answer me, I skim everything.\"),\n", + " (\"assistant\", \"Understood — short answers from here.\"),\n", + " (\"user\", \"Outside work I climb most weekends, and I'm learning German.\"),\n", + " (\"assistant\", \"Bouldering or ropes?\"),\n", + " (\"user\", \"Bouldering. Also — I'm vegetarian, so skip meat in any recipe suggestions.\"),\n", + " (\"assistant\", \"Noted, vegetarian only.\"),\n", + "]\n", + "\n", + "response = client.add(\n", + " [{\"role\": r, \"content\": c} for r, c in TURNS],\n", + " user_id=USER_ID,\n", + ")\n", + "print(json.dumps(response, indent=2)[:300])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The add is **async** — it returns an `event_id` and the memories do not exist yet. Poll\n", + "`GET /v1/event/{event_id}/` until it is `SUCCEEDED` or `FAILED`; that, not a sleep, is how\n", + "you know the add finished. Then let the extracted memories settle.\n", + "\n", + "Under load this can take a minute or more, so the cell says plainly whether it ran out of\n", + "time rather than printing `0 memories` as though that were the answer.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "event_id = response[\"event_id\"]\n", + "deadline = time.time() + 300\n", + "\n", + "# 1. The add itself. Terminal status, not a sleep.\n", + "event_status = None\n", + "while time.time() < deadline:\n", + " event_status = client.client.get(f\"/v1/event/{event_id}/\").json().get(\"status\")\n", + " if event_status in (\"SUCCEEDED\", \"FAILED\"):\n", + " break\n", + " print(f\" add {event_status}\")\n", + " time.sleep(5)\n", + "print(f\"add finished: {event_status}\")\n", + "assert event_status != \"FAILED\", \"the add itself failed — nothing downstream can work\"\n", + "\n", + "# 2. Extraction lands in batches, so the FIRST non-empty page is not the whole set.\n", + "# Wait for the count to stop growing instead of breaking on the first result.\n", + "memories, stable = [], 0\n", + "while time.time() < deadline:\n", + " page = client.get_all(filters={\"user_id\": USER_ID}, page_size=50)\n", + " found = page.get(\"results\", []) if isinstance(page, dict) else page\n", + " stable = stable + 1 if found and len(found) == len(memories) else 0\n", + " memories = found\n", + " if stable >= 2: # two identical polls in a row\n", + " break\n", + " print(f\" ... {len(memories)} so far\")\n", + " time.sleep(5)\n", + "\n", + "if memories:\n", + " print(f\"\\n{len(memories)} memories extracted:\\n\")\n", + " for m in memories:\n", + " print(\" \\u2022\", m.get(\"memory\"))\n", + "else:\n", + " # Say so. Reporting '0 memories' as a result hides a busy or broken environment\n", + " # and makes the profile below look like it came from nothing.\n", + " print(\"\\nNO memories yet — extraction is still catching up, or the ingestion\")\n", + " print(\"worker is down. Everything below will report insufficient_data.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Read the profile\n", + "\n", + "Crossing the 10-message boundary should already have queued a generation. Read first —\n", + "and note that a known user with no profile yet is a **200 with a status**, not a 404. That\n", + "distinction is the whole point of the envelope.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "envelope = client.get_profile(USER_ID)\n", + "print(json.dumps(envelope, indent=2))\n", + "\n", + "print(\"\\nstatus vocabulary:\")\n", + "print(\" succeeded terminal — a generation ran AND the profile has content\")\n", + "print(\" pending queued or running\")\n", + "print(\" failed terminal — the last generation did not complete\")\n", + "print(\" not_enabled feature off, or plan below Pro\")\n", + "print(\" insufficient_data no content to show: no row yet, queued, or a\")\n", + "print(\" generation that legitimately found nothing\")\n", + "print()\n", + "print(\"`succeeded` is decided by the profile BODY, not by generation_count: an\")\n", + "print(\"empty extraction still increments the counter, so counting generations\")\n", + "print(\"reports 'done' for a profile with nothing in it.\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Force it, rather than waiting\n", + "\n", + "`generate_profile()` closes the bootstrapping gap: without it a new user has no profile\n", + "until their tenth message. One entity, a few seconds.\n", + "\n", + "Every call is sent with a fresh `Idempotency-Key`, so a retry after a dropped connection\n", + "returns the same job instead of paying twice.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "TERMINAL = {\"succeeded\", \"failed\", \"not_enabled\"}\n", + "\n", + "\n", + "def wait_for_profile(entity_id, timeout=300, interval=5, since=None):\n", + " \"\"\"Poll until terminal.\n", + "\n", + " `since` waits for a generation_count ABOVE that value, which is how you wait\n", + " for an UPDATE rather than accepting the profile you already had.\n", + "\n", + " `insufficient_data` is NOT terminal by itself — it also covers 'queued', so\n", + " poll through it and give up on the timeout instead.\n", + " \"\"\"\n", + " deadline = time.time() + timeout\n", + " body = None\n", + " while time.time() < deadline:\n", + " body = client.get_profile(entity_id)\n", + " status = (body.get(\"status\") or \"\").lower()\n", + " count = body.get(\"generation_count\") or 0\n", + " fresh = count > since if since is not None else True\n", + " if status == \"succeeded\" and fresh:\n", + " return body\n", + " if status in (\"failed\", \"not_enabled\"):\n", + " raise RuntimeError(f\"generation stopped: {status}\")\n", + " print(f\" ... {status} (generation_count={count})\")\n", + " time.sleep(interval)\n", + " raise TimeoutError(f\"not ready in {timeout}s: {body}\")\n", + "\n", + "\n", + "print(json.dumps(client.generate_profile(USER_ID), indent=2))\n", + "print(\"\\npolling...\")\n", + "\n", + "try:\n", + " body = wait_for_profile(USER_ID)\n", + " print(\"\\n=== PROFILE ===\")\n", + " print(json.dumps(body[\"profile\"], indent=2))\n", + " print(f\"\\nstatus={body['status']} generations={body['generation_count']} updated={body['updated_at']}\")\n", + "except TimeoutError as e:\n", + " # Say so plainly and let the rest of the notebook skip, rather than raising\n", + " # a NameError in every cell below and burying the real cause.\n", + " body = None\n", + " print(f\"\\nNO PROFILE: {e}\")\n", + " print(\"Generation never finished. Usually the ingestion worker is down, or\")\n", + " print(\"this project has no memories for the user yet.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# The model must not invent fields outside your schema — the forced tool call is\n", + "# what makes that structural rather than a request.\n", + "if body is None:\n", + " print(\"skipped — no profile was generated above\")\n", + "else:\n", + " extra = set(body[\"profile\"]) - set(SCHEMA[\"properties\"])\n", + " print(\"fields outside the schema:\", extra or \"none\")\n", + "\n", + " # A forced JSON-Schema response makes the model emit SOMETHING for every property,\n", + " # so 'I found nothing' arrives as a type default: 0, \"\", [].\n", + " filled = {k: v for k, v in body[\"profile\"].items() if v not in (None, \"\", [], {}, 0)}\n", + " print(f\"genuinely populated: {len(filled)}/{len(SCHEMA['properties'])} -> {list(filled)}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Now watch it update\n", + "\n", + "Six more turns that contradict and extend what we already know: a promotion, a move, a\n", + "dropped hobby. A profile is a living document, not an append-only log — the model gets the\n", + "memories and rewrites the whole thing.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if body is None:\n", + " print(\"skipped — no profile was generated above\")\n", + "else:\n", + " before = body[\"generation_count\"]\n", + "\n", + " MORE_TURNS = [\n", + " (\"user\", \"Update — I got promoted to staff engineer last week.\"),\n", + " (\"assistant\", \"Congratulations. Same team?\"),\n", + " (\"user\", \"Same company, but I'm relocating to Munich for it.\"),\n", + " (\"assistant\", \"Big move. How do you feel about it?\"),\n", + " (\"user\", \"Good. I've stopped climbing though — knee injury. Picked up cycling instead.\"),\n", + " (\"assistant\", \"Sorry about the knee. Cycling's kinder on it.\"),\n", + " ]\n", + "\n", + " followup = client.add(\n", + " [{\"role\": r, \"content\": c} for r, c in MORE_TURNS],\n", + " user_id=USER_ID,\n", + " )\n", + "\n", + " # Wait for the add to land before triggering: a generation queued before the new\n", + " # memories exist rewrites the profile from the OLD ones and looks like a no-op.\n", + " deadline = time.time() + 300\n", + " while time.time() < deadline:\n", + " status = client.client.get(f\"/v1/event/{followup['event_id']}/\").json().get(\"status\")\n", + " if status in (\"SUCCEEDED\", \"FAILED\"):\n", + " break\n", + " time.sleep(5)\n", + " print(\"follow-up add:\", status)\n", + " time.sleep(15) # let extraction settle\n", + "\n", + " print(json.dumps(client.generate_profile(USER_ID), indent=2))\n", + " print(f\"\\npolling for a NEW generation (count must exceed {before})...\")\n", + " updated = wait_for_profile(USER_ID, since=before)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if body is None:\n", + " print(\"skipped — no profile was generated above\")\n", + "else:\n", + " print(f\"{'field':<22} {'before':<34} after\")\n", + " print(\"-\" * 92)\n", + " for field in SCHEMA[\"properties\"]:\n", + " b = json.dumps(body[\"profile\"].get(field))\n", + " a = json.dumps(updated[\"profile\"].get(field))\n", + " mark = \" \" if a == b else \"->\"\n", + " print(f\"{mark} {field:<20} {b[:32]:<34} {a[:32]}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Use it in a prompt\n", + "\n", + "The point of the structure is that it drops straight into a prompt — no list of memories\n", + "to summarize, no query to write.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def build_system_prompt(entity_id):\n", + " result = client.get_profile(entity_id)\n", + " if result[\"status\"] != \"succeeded\":\n", + " # Branch on status, never on an empty profile: a user whose profile is\n", + " # still building is not a user you know nothing about.\n", + " return \"You are a helpful assistant.\"\n", + "\n", + " p = result[\"profile\"]\n", + " return f\"\"\"You are helping {entity_id}.\n", + "Occupation: {p.get(\"occupation\", \"unknown\")}\n", + "Location: {p.get(\"location\", \"unknown\")}\n", + "Interests: {\", \".join(p.get(\"interests\", [])) or \"unknown\"}\n", + "Dietary restrictions: {\", \".join(p.get(\"dietary_restrictions\", [])) or \"none stated\"}\n", + "Preferred style: {p.get(\"communication_style\", \"unknown\")}\n", + "\n", + "Match their style and do not explain what they already know.\"\"\"\n", + "\n", + "\n", + "print(build_system_prompt(USER_ID))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Judge a schema before committing to it\n", + "\n", + "`sample_profiles()` runs your schema against up to 10 **real** users that have memories.\n", + "\n", + "These are real generations and the results are **kept** — a dry run would cost exactly the\n", + "same and leave those users no better off. It is not a free preview.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 202, not 200: the sample generations are queued, not finished.\n", + "#\n", + "# A 409 `already_running` means a sample from an earlier run is still going.\n", + "# That is the cooldown working, not an error — reuse that job rather than\n", + "# failing the notebook.\n", + "try:\n", + " job = client.sample_profiles(limit=3)\n", + " print(json.dumps(job, indent=2)[:400])\n", + " print(\"\\nsampled\", job.get(\"sampled\"), \"entities:\", job.get(\"entity_ids\"))\n", + "except Exception as e:\n", + " detail = str(e)\n", + " print(\"sample refused:\", detail[:200])\n", + " running = json.loads(detail).get(\"error\", {}).get(\"job_id\") if detail.startswith(\"{\") else None\n", + " job = {\"job_id\": running, \"status_url\": f\"/v2/profiles/jobs/{running}/\"} if running else None\n", + " print(\"reusing the running job:\", running)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Poll `status_url` to see how the job went. `total` is `null` until enumeration finishes,\n", + "so format it defensively rather than assuming a number.\n" + ] + }, + { + "cell_type": "code", + "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" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Apply a new schema to existing users\n", + "\n", + "A new schema shapes the **next** generation. Profiles that already exist keep their values\n", + "until their user is generated again — which happens as that user sends more memories, or\n", + "when you call `generate_profile()` for them.\n", + "\n", + "A field you **remove** stops being maintained: on the next generation, fields your schema\n", + "no longer defines are pruned. Keep a field for as long as you want its value kept.\n", + "\n", + "Rebuilding a whole project in one call is **not available yet**. Check\n", + "`capabilities.full_rebuild` before offering it in your own UI — the cell below expects to\n", + "be refused, and that refusal is the feature working.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "caps = client.get_profile_settings().get(\"capabilities\", {})\n", + "print(\"full_rebuild available:\", caps.get(\"full_rebuild\"))\n", + "\n", + "if caps.get(\"full_rebuild\"):\n", + " wait_for_job(client.regenerate_profiles())\n", + "else:\n", + " try:\n", + " client.regenerate_profiles()\n", + " print(\"ACCEPTED (unexpected)\")\n", + " except Exception as e:\n", + " # 409 not_yet_available. It creates nothing, so nothing was charged.\n", + " print(\"refused as expected:\", str(e)[:200])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "# Failure scenarios\n", + "\n", + "Everything above is the path that works. These are the ways it says no, and what each one\n", + "means. Run this section last: F3 deliberately leaves the project switched off for a moment.\n", + "\n", + "> **About the `HTTP error occurred:` lines below.** The SDK logs every 4xx at\n", + "> ERROR level before raising, so they appear even for the failures these cells\n", + "> deliberately catch. Read the line printed *after* each one — that is the cell's\n", + "> own verdict. Nothing here is unhandled.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## F1. Schemas that get rejected\n", + "\n", + "Rejections happen on **write**, where you can see and fix them — not silently at\n", + "generation time, where you would only notice as an empty profile weeks later.\n", + "\n", + "The last case matters for storage: both entity kinds live in one JSONB column alongside\n", + "the envelope that separates them, so a schema using the envelope's own reserved keys could\n", + "not be read back unambiguously. It is refused rather than stored.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "BAD_SCHEMAS = [\n", + " ({\"type\": \"object\", \"properties\": {}}, \"empty — bills you to extract nothing\"),\n", + " ({\"type\": \"array\", \"items\": {\"type\": \"string\"}}, \"root must be an object\"),\n", + " (\n", + " {\n", + " \"type\": \"object\",\n", + " \"properties\": {\"tone\": {\"type\": \"string\", \"description\": \"Preferred tone.\"}},\n", + " # At the schema ROOT, which is where the envelope's own keys live.\n", + " \"_profile_config_version\": 1,\n", + " \"entities\": {\"user\": {}},\n", + " },\n", + " \"reserved settings keys at the schema root\",\n", + " ),\n", + "]\n", + "\n", + "for bad, why in BAD_SCHEMAS:\n", + " try:\n", + " client.update_profile_settings(schema=bad)\n", + " print(f\"ACCEPTED (unexpected): {why}\")\n", + " except Exception as e:\n", + " print(f\"rejected [{why}]:\\n {str(e)[:160]}\\n\")\n", + "\n", + "# NOT rejected: a property with no description. The validator allows it and the\n", + "# model then has nothing to go on, so the field comes back empty. Descriptions are\n", + "# your job, not the validator's.\n", + "try:\n", + " client.update_profile_settings(schema={\"type\": \"object\", \"properties\": {\"x\": {\"type\": \"string\"}}})\n", + " print(\"accepted [no description on 'x'] <- the trap: valid to store, useless to generate\")\n", + "finally:\n", + " client.update_profile_settings(schema=SCHEMA) # put the good one back\n", + "\n", + "restored = client.get_profile_settings()[\"entities\"][\"user\"][\"schema\"]\n", + "assert set(restored[\"properties\"]) == set(SCHEMA[\"properties\"])\n", + "print(\"\\nschema restored:\", sorted(restored[\"properties\"]))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## F2. A user that does not exist\n", + "\n", + "404 means only \"no such user\". A known user with no profile yet is a 200 carrying\n", + "`insufficient_data`, so an ordinary empty state never looks like an error.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from mem0.exceptions import MemoryNotFoundError\n", + "\n", + "try:\n", + " client.get_profile(\"user_who_never_existed\")\n", + " print(\"ACCEPTED (unexpected)\")\n", + "except MemoryNotFoundError as e:\n", + " print(\"404 as intended:\", str(e)[:120])\n", + "\n", + "# ...versus a real user who simply has no profile row yet.\n", + "fresh = f\"demo_never_profiled_{uuid.uuid4().hex[:6]}\"\n", + "client.add([{\"role\": \"user\", \"content\": \"One passing remark.\"}], user_id=fresh)\n", + "time.sleep(5)\n", + "print(\"known but unprofiled:\", client.get_profile(fresh)[\"status\"])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## F3. Profiles turned off\n", + "\n", + "`enabled` is the one project-wide switch. Every generation path then refuses.\n", + "\n", + "Nothing is deleted. Your schema and every profile you already built are kept, so turning\n", + "it back on resumes rather than restarts.\n", + "\n", + "Note what a read does **not** do — a profile that already exists keeps reporting\n", + "`succeeded` and keeps returning its content. `not_enabled` is only what you get for a user\n", + "with no profile yet. Turning the feature off stops new work; it does not hide what has\n", + "already been built.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "client.update_profile_settings(enabled=False)\n", + "\n", + "print(\"read (demo user) :\", client.get_profile(USER_ID)[\"status\"])\n", + "print(\"read (never profiled) :\", client.get_profile(fresh)[\"status\"])\n", + "try:\n", + " client.generate_profile(USER_ID)\n", + " print(\"trigger: ACCEPTED (unexpected)\")\n", + "except Exception as e:\n", + " print(\"trigger:\", str(e)[:160])\n", + "\n", + "back = client.update_profile_settings(enabled=True) # put it back\n", + "print(\"\\nrestored:\", back[\"enabled\"])\n", + "print(\"schema survived:\", bool(back[\"entities\"][\"user\"][\"schema\"]))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. Cleanup\n", + "\n", + "Removes the demo users. The profile row cascades with the entity.\n" + ] + }, + { + "cell_type": "code", + "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" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Cheat sheet\n", + "\n", + "| Want | Call | Cost |\n", + "| --- | --- | --- |\n", + "| configure | `update_profile_settings(...)` | free |\n", + "| read | `get_profile(user_id)` | free |\n", + "| one user now | `generate_profile(user_id)` | 1 LLM call |\n", + "| try a schema | `sample_profiles(limit=n)` | ≤10 real generations, kept |\n", + "| everyone | `regenerate_profiles()` | **not available yet** — 409 |\n", + "| poll a job | `get_profile_job(status_url)` | free |\n", + "\n", + "**Settings are per entity kind.** The stored shape is:\n", + "\n", + "```json\n", + "{\"enabled\": true,\n", + " \"entities\": {\"user\": {\"schema\": {...}, \"custom_instructions\": \"...\"}},\n", + " \"capabilities\": {\"full_rebuild\": false}}\n", + "```\n", + "\n", + "The SDK takes these flat and nests them for you. Only the fields you pass are written;\n", + "`enabled` is the one project-wide switch.\n", + "\n", + "**Left alone, generation fires** on a 10-message boundary, or after a flush window\n", + "measured in hours. `generate_profile()` is how you skip the wait for one user.\n", + "\n", + "**Three traps:**\n", + "\n", + "1. `insufficient_data` is not a terminal verdict — it also covers \"queued\", so poll\n", + " through it and give up on a timeout instead.\n", + "2. `succeeded` is decided by the profile **body**, not `generation_count`. An empty\n", + " extraction still increments the counter.\n", + "3. A forced JSON-Schema response emits something for every property, so \"nothing found\"\n", + " arrives as a type default — `\"\"`, `[]`, `0` — not as a missing key.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/mem0-ts/src/client/index.ts b/mem0-ts/src/client/index.ts index 98f1849a0..4b6b283df 100644 --- a/mem0-ts/src/client/index.ts +++ b/mem0-ts/src/client/index.ts @@ -28,6 +28,8 @@ export type { ProfileResponse, ProfileTriggerResponse, ProfileSettings, + ProfileSettingsResponse, + EntityProfileSettings, ProfileSampleResult, ProfileSamplesResponse, ProfileRegenerateResponse, diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 3cb2f57ba..1df9233cf 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -26,6 +26,7 @@ import { ProfileJobStatus, ProfileTriggerResponse, ProfileSettings, + ProfileSettingsResponse, ProfileSamplesResponse, ProfileRegenerateResponse, } from "./mem0.types"; @@ -823,7 +824,7 @@ export default class MemoryClient { } /** Get the profile settings for the current project. */ - async getProfileSettings(): Promise { + async getProfileSettings(): Promise { this._captureEvent("get_profile_settings", []); await this._awaitIdentity(); @@ -833,28 +834,78 @@ export default class MemoryClient { return this._settingsWithVerbatimSchema(raw); } - /** The envelope keys are ours; the schema's property names are the customer's. */ - private _settingsWithVerbatimSchema(raw: any): ProfileSettings { - const settings = snakeToCamelKeys(raw) as ProfileSettings; - if (raw && typeof raw === "object" && "schema" in raw) { + /** + * The envelope keys are ours; the schema's property names are the customer's. + * + * Each entity type carries its own schema, so every one has to be restored + * from the raw body — otherwise camel-casing rewrites the customer's field + * names and a profile comes back under keys they never chose. + */ + private _settingsWithVerbatimSchema(raw: any): ProfileSettingsResponse { + const settings = snakeToCamelKeys(raw) as ProfileSettingsResponse; + if (!raw || typeof raw !== "object") { + return settings; + } + + if ("schema" in raw) { settings.schema = raw.schema; } + + const rawEntities = raw.entities; + if (rawEntities && typeof rawEntities === "object") { + for (const [entityType, entitySettings] of Object.entries(rawEntities)) { + if ( + entitySettings && + typeof entitySettings === "object" && + "schema" in entitySettings && + settings.entities?.[entityType as ProfileEntityType] + ) { + settings.entities[entityType as ProfileEntityType]!.schema = ( + entitySettings as Record + ).schema; + } + } + } + return settings; } - /** Update profile settings. Only the fields you pass are written. */ + /** + * Update profile settings. Only the fields you pass are written. + * + * `schema` and `customInstructions` are per entity type and are nested under + * `entities` for the API; only `enabled` is project-wide. Sending them flat + * is rejected with `Unsupported settings`. + */ async updateProfileSettings( settings: ProfileSettings, - ): Promise { + ): Promise { const payloadKeys = Object.keys(settings || {}); this._captureEvent("update_profile_settings", [payloadKeys]); await this._awaitIdentity(); + const { + schema, + customInstructions, + enabled, + entityType = "user", + } = settings || {}; + + const body: Record = {}; + if (enabled !== undefined) { + body.enabled = enabled; + } + + const entitySettings: Record = {}; // The schema's property names are the customer's and must reach the API verbatim. - const { schema, ...rest } = settings; - const body: Record = camelToSnakeKeys(rest); if (schema !== undefined) { - body.schema = schema; + entitySettings.schema = schema; + } + if (customInstructions !== undefined) { + entitySettings.custom_instructions = customInstructions; + } + if (Object.keys(entitySettings).length > 0) { + body.entities = { [entityType]: entitySettings }; } const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, { @@ -871,7 +922,10 @@ export default class MemoryClient { * Real generations against real memories, and the results are kept: the * profiles are written to those entities and count toward usage. */ - async sampleProfiles(data?: { limit?: number }): Promise { + async sampleProfiles(data?: { + limit?: number; + entityType?: ProfileEntityType; + }): Promise { this._captureEvent("sample_profiles", []); await this._awaitIdentity(); @@ -882,6 +936,8 @@ export default class MemoryClient { headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ operation: "sample", + // Required: the API refuses a job that does not name an entity kind. + entity_type: data?.entityType ?? "user", ...this._prepareParams({ limit: data?.limit }), }), }, @@ -890,13 +946,15 @@ export default class MemoryClient { } /** - * Rebuild the profile of every entity in the project. + * Rebuild the profile of every entity of one kind in the project. * - * Not available yet: the server answers 501 `not_yet_available` and creates + * Not available yet: the server answers 409 `not_yet_available` and creates * nothing. Use {@link sampleProfiles} or {@link generateProfile} until * `capabilities.full_rebuild` from {@link getProfileSettings} is true. */ - async regenerateProfiles(): Promise { + async regenerateProfiles(data?: { + entityType?: ProfileEntityType; + }): Promise { this._captureEvent("regenerate_profiles", []); await this._awaitIdentity(); @@ -905,7 +963,10 @@ export default class MemoryClient { { method: "POST", headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() }, - body: JSON.stringify({ operation: "regenerate" }), + body: JSON.stringify({ + operation: "regenerate", + entity_type: data?.entityType ?? "user", + }), }, ); return response; diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index 90ed2496c..4327ce2f7 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -242,13 +242,14 @@ export interface GetMemoryExportPayload { /** Entity kinds that can carry a profile. */ export type ProfileEntityType = "user" | "agent"; -/** `succeeded` is the only state in which `profile` is guaranteed to hold content. */ +/** + * `succeeded` is the only state in which `profile` is guaranteed to hold content. + * + * These are values, not keys, so the client does not camel-case them: the wire + * spelling is what a comparison has to match. + */ export type ProfileStatus = - | "succeeded" - | "pending" - | "failed" - | "notEnabled" - | "insufficientData"; + "succeeded" | "pending" | "failed" | "not_enabled" | "insufficient_data"; export interface ProfileResponse { /** Shaped by the project's schema; keys are not camel-cased. */ @@ -270,19 +271,45 @@ export interface ProfileJobResponse { usageUnits?: number; eventId?: string; replayed?: boolean; - /** Sample runs only. */ + /** 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 one entity type. */ export interface ProfileSettings { + /** Turn profile generation on or off. Project-wide. */ enabled?: boolean; /** JSON Schema for the profile. Every property needs a `description`. */ schema?: Record | null; customInstructions?: string | null; + /** Which entity kind `schema` and `customInstructions` belong to. Defaults to "user". */ + entityType?: ProfileEntityType; +} + +/** One entity type's stored configuration. */ +export interface EntityProfileSettings { + /** The customer's JSON Schema, with its property names verbatim. */ + schema?: Record | null; + customInstructions?: string | null; + [key: string]: any; +} + +/** + * The settings as stored. `schema` and `customInstructions` are per entity + * type and nest under `entities`; only `enabled` is project-wide. + */ +export interface ProfileSettingsResponse { + enabled?: boolean; + entities?: Partial>; + capabilities?: Record; + [key: string]: any; } export interface ProfileSampleResult { diff --git a/mem0-ts/src/client/tests/memoryClient.profiles.test.ts b/mem0-ts/src/client/tests/memoryClient.profiles.test.ts index 9e77e9e34..14f25d829 100644 --- a/mem0-ts/src/client/tests/memoryClient.profiles.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.profiles.test.ts @@ -4,6 +4,7 @@ * profile/schema keys, not mock response echo. */ import { MemoryClient } from "../mem0"; +import type { ProfileStatus } from "../mem0.types"; import { TEST_API_KEY } from "./helpers"; import { setupMockFetch, @@ -50,6 +51,31 @@ describe("MemoryClient - getProfile()", () => { expect(result.status).toBe("succeeded"); }); + test("status keeps its wire spelling", async () => { + // A status is a VALUE, not a key, so the client does not camel-case it. + // Declaring the union as `insufficientData` made tsc reject the comparison + // that works and accept one that can never be true. + const extra = new Map(); + extra.set("/v2/entities/user/bob/profile/", { + status: 200, + body: { + profile: {}, + status: "insufficient_data", + entity_type: "user", + entity_id: "bob", + }, + }); + setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + const result = await client.getProfile({ entityId: "bob" }); + + expect(result.status).toBe("insufficient_data"); + // Assignable without a cast: the declared union must contain the wire value. + const status: ProfileStatus = result.status; + expect(status).not.toBe("insufficientData"); + }); + test("defaults to user and encodes the entity id", async () => { const extra = new Map(); extra.set("/v2/entities/agent/", { @@ -116,8 +142,12 @@ describe("MemoryClient - profile settings", () => { status: 200, body: { enabled: true, - schema, - custom_instructions: "Focus on durable preferences", + entities: { + user: { + schema, + custom_instructions: "Focus on durable preferences", + }, + }, }, }); const mock = setupMockFetch(extra); @@ -133,19 +163,50 @@ describe("MemoryClient - profile settings", () => { expect(call).toBeDefined(); const body = getFetchBody(call!); - // Mixed casing goes out exactly as written. - expect(body.schema).toEqual(schema); - expect(body.custom_instructions).toBe("Focus on durable preferences"); + // Mixed casing goes out exactly as written, nested under the entity type. + expect(body.entities.user.schema).toEqual(schema); + expect(body.entities.user.custom_instructions).toBe( + "Focus on durable preferences", + ); expect(body.enabled).toBe(true); - expect(result.schema).toEqual(schema); - expect(result.customInstructions).toBe("Focus on durable preferences"); + // A flat schema is rejected by the API with "Unsupported settings". + expect("schema" in body).toBe(false); + + // And the customer's property names survive the round trip. + expect(result.entities?.user?.schema).toEqual(schema); + expect(result.entities?.user?.customInstructions).toBe( + "Focus on durable preferences", + ); + }); + + test("targets the entity type the caller named", async () => { + const extra = new Map(); + extra.set("/v2/profiles/settings/", { + status: 200, + body: { enabled: true }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.updateProfileSettings({ + schema: { type: "object", properties: {} }, + entityType: "agent", + }); + + const body = getFetchBody( + findFetchCall(mock, "/v2/profiles/settings/", "POST")!, + ); + expect(Object.keys(body.entities)).toEqual(["agent"]); }); test("omits fields the caller did not set", async () => { const extra = new Map(); extra.set("/v2/profiles/settings/", { status: 200, - body: { enabled: false, schema: null, custom_instructions: null }, + body: { + enabled: false, + entities: { user: { schema: null, custom_instructions: null } }, + }, }); const mock = setupMockFetch(extra); @@ -155,6 +216,8 @@ describe("MemoryClient - profile settings", () => { const call = findFetchCall(mock, "/v2/profiles/settings/", "POST"); const body = getFetchBody(call!); expect(body.enabled).toBe(false); + // Nothing entity-scoped was passed, so no entities key is sent at all. + expect("entities" in body).toBe(false); expect("schema" in body).toBe(false); expect("custom_instructions" in body).toBe(false); }); @@ -165,8 +228,13 @@ describe("MemoryClient - profile settings", () => { status: 200, body: { enabled: true, - schema: { properties: { favorite_topics: { type: "array" } } }, - custom_instructions: null, + entities: { + user: { + schema: { properties: { favorite_topics: { type: "array" } } }, + custom_instructions: null, + }, + }, + capabilities: { full_rebuild: false }, }, }); const mock = setupMockFetch(extra); @@ -176,9 +244,10 @@ describe("MemoryClient - profile settings", () => { expect(findFetchCall(mock, "/v2/profiles/settings/")).toBeDefined(); expect(result.enabled).toBe(true); - expect(result.schema).toEqual({ + expect(result.entities?.user?.schema).toEqual({ properties: { favorite_topics: { type: "array" } }, }); + expect(result.capabilities?.fullRebuild).toBe(false); }); }); @@ -203,7 +272,11 @@ describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => { await client.sampleProfiles(); const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST"); - expect(getFetchBody(call!)).toEqual({ operation: "sample" }); + // Every job names an entity kind: the API refuses one that does not. + expect(getFetchBody(call!)).toEqual({ + operation: "sample", + entity_type: "user", + }); }); test("sampleProfiles passes an explicit limit", async () => { @@ -228,9 +301,25 @@ describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => { const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST"); expect(getFetchBody(call!).operation).toBe("sample"); expect(getFetchBody(call!).limit).toBe(3); + expect(getFetchBody(call!).entity_type).toBe("user"); expect(result.sampled).toBe(3); }); + test("sampleProfiles targets the entity type the caller named", async () => { + const extra = new Map(); + extra.set("/v2/profiles/jobs/", { + status: 202, + body: { job_id: "job_4", status: "QUEUED", entity_type: "agent" }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.sampleProfiles({ entityType: "agent" }); + + const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST"); + expect(getFetchBody(call!).entity_type).toBe("agent"); + }); + test("sends operation regenerate to the jobs collection", async () => { const extra = new Map(); extra.set("/v2/profiles/jobs/", { @@ -248,7 +337,12 @@ describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => { const client = new MemoryClient({ apiKey: TEST_API_KEY }); const result = await client.regenerateProfiles(); - expect(findFetchCall(mock, "/v2/profiles/jobs/", "POST")).toBeDefined(); + const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST"); + expect(call).toBeDefined(); + expect(getFetchBody(call!)).toEqual({ + operation: "regenerate", + entity_type: "user", + }); expect(result.jobId).toBe("job_3"); expect(result.statusUrl).toBe("/v2/profiles/jobs/job_3/"); }); diff --git a/mem0/client/main.py b/mem0/client/main.py index 425640c52..be4666474 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -42,6 +42,38 @@ ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"}) # One collection for every generation; the operation is a body field. PROFILE_JOBS_PATH = "/v2/profiles/jobs/" +PROFILE_SETTINGS_PATH = "/v2/profiles/settings/" + + +def _profile_settings_payload( + enabled: Optional[bool], + schema: Optional[Dict[str, Any]], + custom_instructions: Optional[str], + entity_type: str, +) -> Dict[str, Any]: + """Build the settings body the API accepts. + + ``schema`` and ``custom_instructions`` are per entity type and nest under + ``entities``; only ``enabled`` is project-wide. This mirrors what + ``get_profile_settings`` returns, so the two round-trip. + + Sending them flat is rejected with ``Unsupported settings``, so this shape is + not cosmetic. + """ + + payload: Dict[str, Any] = {} + if enabled is not None: + payload["enabled"] = enabled + + entity_settings: Dict[str, Any] = {} + if schema is not None: + entity_settings["schema"] = schema + if custom_instructions is not None: + entity_settings["custom_instructions"] = custom_instructions + + if entity_settings: + payload["entities"] = {entity_type: entity_settings} + return payload def _validate_and_trim_search_query(query: str) -> str: @@ -751,10 +783,12 @@ class MemoryClient: """Get the profile settings for the current project. Returns: - Dict with ``enabled``, ``schema`` and ``custom_instructions``. + Dict with ``enabled`` and ``capabilities`` at the top level, and + ``entities`` holding each entity type's ``schema`` and + ``custom_instructions``. """ - response = self.client.get("/v2/profiles/settings/") + response = self.client.get(PROFILE_SETTINGS_PATH) response.raise_for_status() capture_client_event("client.get_profile_settings", self, {"sync_type": "sync"}) return response.json() @@ -765,36 +799,41 @@ class MemoryClient: enabled: Optional[bool] = None, schema: Optional[Dict[str, Any]] = None, custom_instructions: Optional[str] = None, + entity_type: str = "user", ) -> Dict[str, Any]: """Update the profile settings for the current project. Only the arguments you pass are written. Args: - enabled: Turn profile generation on or off. + enabled: Turn profile generation on or off. Project-wide. schema: JSON Schema for the profile. Every property needs a - ``description``. + ``description``. Applies to ``entity_type``. custom_instructions: Extra guidance for the extraction step. + Applies to ``entity_type``. + entity_type: Which entity kind ``schema`` and + ``custom_instructions`` belong to. Defaults to "user". Returns: - Dict with the settings as stored after the update. + Dict with the settings as stored after the update, in the same + shape :meth:`get_profile_settings` returns. Raises: ValidationError: If the schema is not a valid profile schema. """ - payload = self._prepare_params( - {"enabled": enabled, "schema": schema, "custom_instructions": custom_instructions} - ) - response = self.client.post("/v2/profiles/settings/", json=payload) + payload = _profile_settings_payload(enabled, schema, custom_instructions, entity_type) + response = self.client.post(PROFILE_SETTINGS_PATH, json=payload) response.raise_for_status() capture_client_event( - "client.update_profile_settings", self, {"keys": list(payload.keys()), "sync_type": "sync"} + "client.update_profile_settings", + self, + {"keys": list(payload.keys()), "entity_type": entity_type, "sync_type": "sync"}, ) 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, entity_type: str = "user") -> Dict[str, Any]: """Generate profiles for a few real entities, to check a schema. Real generations against real memories, and the results are kept. The @@ -802,9 +841,11 @@ class MemoryClient: Args: limit: How many entities to sample, 1-10. Defaults to the server value. + entity_type: Which entity kind to sample. Defaults to "user". Returns: - Dict containing ``job_id``, ``status`` and ``status_url``. Poll + Dict containing ``job_id``, ``status``, ``status_url``, ``sampled`` + and the ``entity_ids`` that were picked. Poll :meth:`get_profile_job` with ``status_url`` until the status is terminal. Raises: @@ -814,23 +855,27 @@ class MemoryClient: payload = self._prepare_params({"limit": limit}) payload["operation"] = "sample" + payload["entity_type"] = entity_type response = self.client.post( PROFILE_JOBS_PATH, json=payload, headers={"Idempotency-Key": uuid.uuid4().hex}, ) response.raise_for_status() - capture_client_event("client.sample_profiles", self, {"sync_type": "sync"}) + capture_client_event("client.sample_profiles", self, {"entity_type": entity_type, "sync_type": "sync"}) return response.json() @api_error_handler - def regenerate_profiles(self) -> Dict[str, Any]: - """Rebuild the profile of every entity in the current project. + def regenerate_profiles(self, entity_type: str = "user") -> Dict[str, Any]: + """Rebuild the profile of every entity of one kind in the project. - Not available yet: the server answers 501 with ``not_yet_available`` and + Not available yet: the server answers 409 with ``not_yet_available`` and creates nothing. Use :meth:`sample_profiles` or :meth:`generate_profile` until ``capabilities.full_rebuild`` in :meth:`get_profile_settings` is true. + Args: + entity_type: Which entity kind to rebuild. Defaults to "user". + Returns: Dict containing ``job_id``, ``status`` and ``status_url``. @@ -841,11 +886,11 @@ class MemoryClient: response = self.client.post( PROFILE_JOBS_PATH, - json={"operation": "regenerate"}, + json={"operation": "regenerate", "entity_type": entity_type}, headers={"Idempotency-Key": uuid.uuid4().hex}, ) response.raise_for_status() - capture_client_event("client.regenerate_profiles", self, {"sync_type": "sync"}) + capture_client_event("client.regenerate_profiles", self, {"entity_type": entity_type, "sync_type": "sync"}) return response.json() @api_error_handler @@ -1839,10 +1884,12 @@ class AsyncMemoryClient: """Get the profile settings for the current project. Returns: - Dict with ``enabled``, ``schema`` and ``custom_instructions``. + Dict with ``enabled`` and ``capabilities`` at the top level, and + ``entities`` holding each entity type's ``schema`` and + ``custom_instructions``. """ - response = await self.async_client.get("/v2/profiles/settings/") + response = await self.async_client.get(PROFILE_SETTINGS_PATH) response.raise_for_status() capture_client_event("client.get_profile_settings", self, {"sync_type": "async"}) return response.json() @@ -1853,36 +1900,41 @@ class AsyncMemoryClient: enabled: Optional[bool] = None, schema: Optional[Dict[str, Any]] = None, custom_instructions: Optional[str] = None, + entity_type: str = "user", ) -> Dict[str, Any]: """Update the profile settings for the current project. Only the arguments you pass are written. Args: - enabled: Turn profile generation on or off. + enabled: Turn profile generation on or off. Project-wide. schema: JSON Schema for the profile. Every property needs a - ``description``. + ``description``. Applies to ``entity_type``. custom_instructions: Extra guidance for the extraction step. + Applies to ``entity_type``. + entity_type: Which entity kind ``schema`` and + ``custom_instructions`` belong to. Defaults to "user". Returns: - Dict with the settings as stored after the update. + Dict with the settings as stored after the update, in the same + shape :meth:`get_profile_settings` returns. Raises: ValidationError: If the schema is not a valid profile schema. """ - payload = self._prepare_params( - {"enabled": enabled, "schema": schema, "custom_instructions": custom_instructions} - ) - response = await self.async_client.post("/v2/profiles/settings/", json=payload) + payload = _profile_settings_payload(enabled, schema, custom_instructions, entity_type) + response = await self.async_client.post(PROFILE_SETTINGS_PATH, json=payload) response.raise_for_status() capture_client_event( - "client.update_profile_settings", self, {"keys": list(payload.keys()), "sync_type": "async"} + "client.update_profile_settings", + self, + {"keys": list(payload.keys()), "entity_type": entity_type, "sync_type": "async"}, ) 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, entity_type: str = "user") -> Dict[str, Any]: """Generate profiles for a few real entities, to check a schema. Real generations against real memories, and the results are kept. The @@ -1890,9 +1942,11 @@ class AsyncMemoryClient: Args: limit: How many entities to sample, 1-10. Defaults to the server value. + entity_type: Which entity kind to sample. Defaults to "user". Returns: - Dict containing ``job_id``, ``status`` and ``status_url``. Poll + Dict containing ``job_id``, ``status``, ``status_url``, ``sampled`` + and the ``entity_ids`` that were picked. Poll :meth:`get_profile_job` with ``status_url`` until the status is terminal. Raises: @@ -1902,23 +1956,27 @@ class AsyncMemoryClient: payload = self._prepare_params({"limit": limit}) payload["operation"] = "sample" + payload["entity_type"] = entity_type response = await self.async_client.post( PROFILE_JOBS_PATH, json=payload, headers={"Idempotency-Key": uuid.uuid4().hex}, ) response.raise_for_status() - capture_client_event("client.sample_profiles", self, {"sync_type": "async"}) + capture_client_event("client.sample_profiles", self, {"entity_type": entity_type, "sync_type": "async"}) return response.json() @api_error_handler - async def regenerate_profiles(self) -> Dict[str, Any]: - """Rebuild the profile of every entity in the current project. + async def regenerate_profiles(self, entity_type: str = "user") -> Dict[str, Any]: + """Rebuild the profile of every entity of one kind in the project. - Not available yet: the server answers 501 with ``not_yet_available`` and + Not available yet: the server answers 409 with ``not_yet_available`` and creates nothing. Use :meth:`sample_profiles` or :meth:`generate_profile` until ``capabilities.full_rebuild`` in :meth:`get_profile_settings` is true. + Args: + entity_type: Which entity kind to rebuild. Defaults to "user". + Returns: Dict containing ``job_id``, ``status`` and ``status_url``. @@ -1929,11 +1987,13 @@ class AsyncMemoryClient: response = await self.async_client.post( PROFILE_JOBS_PATH, - json={"operation": "regenerate"}, + json={"operation": "regenerate", "entity_type": entity_type}, headers={"Idempotency-Key": uuid.uuid4().hex}, ) response.raise_for_status() - capture_client_event("client.regenerate_profiles", self, {"sync_type": "async"}) + capture_client_event( + "client.regenerate_profiles", self, {"entity_type": entity_type, "sync_type": "async"} + ) return response.json() @api_error_handler diff --git a/tests/test_client_profiles.py b/tests/test_client_profiles.py index 7099c3e4b..3b425cd6d 100644 --- a/tests/test_client_profiles.py +++ b/tests/test_client_profiles.py @@ -112,7 +112,7 @@ class TestGenerateProfile: class TestProfileSettings: def test_get_reads_v2(self, mock_memory_client): mock_memory_client.client.get.return_value = _mock_response( - {"enabled": True, "schema": None, "custom_instructions": None} + {"enabled": True, "entities": {"user": {"schema": None, "custom_instructions": None}}} ) mock_memory_client.get_profile_settings() @@ -130,6 +130,30 @@ class TestProfileSettings: json={"enabled": False}, ) + def test_update_nests_schema_under_entities(self, mock_memory_client): + """The API takes only ``enabled`` and ``entities`` at the top level. + + A flat body is rejected with ``Unsupported settings``, so this nesting is + what makes the call work at all. + """ + schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}} + mock_memory_client.client.post.return_value = _mock_response({"enabled": True}) + + mock_memory_client.update_profile_settings(enabled=True, schema=schema) + + _, kwargs = mock_memory_client.client.post.call_args + assert set(kwargs["json"]) == {"enabled", "entities"} + assert "schema" not in kwargs["json"] + + def test_update_targets_the_named_entity_type(self, mock_memory_client): + schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}} + mock_memory_client.client.post.return_value = _mock_response({"enabled": True}) + + mock_memory_client.update_profile_settings(schema=schema, entity_type="agent") + + _, kwargs = mock_memory_client.client.post.call_args + assert kwargs["json"] == {"entities": {"agent": {"schema": schema}}} + def test_update_passes_schema_verbatim(self, mock_memory_client): schema = { "type": "object", @@ -141,30 +165,44 @@ class TestProfileSettings: } }, } - mock_memory_client.client.post.return_value = _mock_response({"enabled": True, "schema": schema}) + mock_memory_client.client.post.return_value = _mock_response({"enabled": True}) mock_memory_client.update_profile_settings(enabled=True, schema=schema, custom_instructions="Keep it durable") mock_memory_client.client.post.assert_called_once_with( "/v2/profiles/settings/", - json={"enabled": True, "schema": schema, "custom_instructions": "Keep it durable"}, + json={ + "enabled": True, + "entities": {"user": {"schema": schema, "custom_instructions": "Keep it durable"}}, + }, ) class TestSampleAndRegenerate: def test_sample_without_limit(self, mock_memory_client): - mock_memory_client.client.post.return_value = _mock_response({"sampled": 5, "results": []}) + mock_memory_client.client.post.return_value = _mock_response({"sampled": 5, "entity_ids": []}) mock_memory_client.sample_profiles() - _assert_job_call(mock_memory_client.client.post, {"operation": "sample"}) + _assert_job_call(mock_memory_client.client.post, {"operation": "sample", "entity_type": "user"}) def test_sample_with_limit(self, mock_memory_client): - mock_memory_client.client.post.return_value = _mock_response({"sampled": 3, "results": []}) + mock_memory_client.client.post.return_value = _mock_response({"sampled": 3, "entity_ids": []}) mock_memory_client.sample_profiles(limit=3) - _assert_job_call(mock_memory_client.client.post, {"operation": "sample", "limit": 3}) + _assert_job_call( + mock_memory_client.client.post, + {"operation": "sample", "limit": 3, "entity_type": "user"}, + ) + + def test_sample_names_the_entity_type(self, mock_memory_client): + """Every job names an entity kind: the API refuses one that does not.""" + mock_memory_client.client.post.return_value = _mock_response({"sampled": 1, "entity_ids": []}) + + mock_memory_client.sample_profiles(entity_type="agent") + + _assert_job_call(mock_memory_client.client.post, {"operation": "sample", "entity_type": "agent"}) def test_regenerate(self, mock_memory_client): mock_memory_client.client.post.return_value = _mock_response( @@ -173,7 +211,7 @@ class TestSampleAndRegenerate: mock_memory_client.regenerate_profiles() - _assert_job_call(mock_memory_client.client.post, {"operation": "regenerate"}) + _assert_job_call(mock_memory_client.client.post, {"operation": "regenerate", "entity_type": "user"}) class TestAsyncClientParity: @@ -225,9 +263,21 @@ class TestAsyncClientParity: json={"enabled": True}, ) + def test_update_settings_nests_schema(self, async_client): + """The async client builds the same body as the sync one.""" + schema = {"type": "object", "properties": {"x": {"type": "string", "description": "d"}}} + async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True})) + + asyncio.run(async_client.update_profile_settings(enabled=True, schema=schema)) + + async_client.async_client.post.assert_called_once_with( + "/v2/profiles/settings/", + json={"enabled": True, "entities": {"user": {"schema": schema}}}, + ) + def test_regenerate(self, async_client): async_client.async_client.post = AsyncMock(return_value=_mock_response({"status": "accepted"})) asyncio.run(async_client.regenerate_profiles()) - _assert_job_call(async_client.async_client.post, {"operation": "regenerate"}) + _assert_job_call(async_client.async_client.post, {"operation": "regenerate", "entity_type": "user"})