fix(profiles): correct the settings shape, job entity_type and status type

Four bugs that made the profile SDK unusable against the live API, each
found by running the demo notebook end to end rather than by reading.

1. `update_profile_settings()` sent a flat body:

       {"enabled": ..., "schema": ..., "custom_instructions": ...}

   The API takes only `enabled` and `entities` at the top level and
   answers 400 "Unsupported settings: custom_instructions, schema." to
   anything else, so every call passing a schema failed.
   `get_profile_settings()` already returned the nested shape, so the read
   and the write disagreed and the method could not round-trip its own
   settings. Both SDKs now nest `schema` and `custom_instructions` under
   `entities.<entity_type>` while keeping the flat call signature;
   `entity_type` is a new optional argument defaulting to "user".

2. `sample_profiles()` and `regenerate_profiles()` never sent
   `entity_type`. Every profile job must name an entity kind, so both
   failed with "entity_type must be one of: user, agent."

3. The TypeScript read path rewrote the customer's schema property names.
   Once the schema moved under `entities`, `snakeToCamelKeys` camel-cased
   the keys inside it, because only a top-level schema was restored
   verbatim. A field named `favorite_topics` came back as `favoriteTopics`.

4. `ProfileStatus` declared `notEnabled` and `insufficientData`, but a
   status is a value, not a key, so it is never camel-cased. tsc rejected
   `status === "insufficient_data"`, which is true at runtime, and accepted
   `status === "insufficientData"`, which can never fire. Branching on
   status is the documented way to use a profile, so the type steered
   every TypeScript caller into a dead branch.

Response-shape corrections found alongside them: sample returns
`entity_ids` on create and a richer `results` array on the job, so the TS
`results` field on the create response is marked deprecated and never set;
regenerate answers 409, not the 501 the docstrings claimed.

Verified against a live environment, from both SDKs: a settings write
followed by a read returns the schema property for property, a partial
update no longer blanks it, sample returns 202 with the entities it
picked, regenerate reaches the server and answers its real
not_yet_available, and a user with no profile returns "insufficient_data".

Adds a user-profiles demo notebook covering the whole loop: schema,
ingestion, generation, a before/after diff of a profile rewriting itself,
schema sampling, and a failure-scenario section for each way the API says
no. It polls the add event to a terminal status instead of sleeping, waits
for the extracted memory count to settle rather than trusting the first
page, and reports plainly when generation cannot finish instead of
presenting an empty profile as a result. Executed end to end: 0 failing
cells.

- python: 20 passed (tests/test_client_profiles.py)
- typescript: 203 passed (src/client/tests/)
This commit is contained in:
Pratik
2026-09-17 17:34:54 -07:00
parent 414aef6f1a
commit 949b026991
8 changed files with 1230 additions and 81 deletions
+23 -1
View File
@@ -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`.
<Note>
Profile settings are per project. An API key is scoped to one project, so profiles never cross a project boundary.
+833
View File
@@ -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
}
+2
View File
@@ -28,6 +28,8 @@ export type {
ProfileResponse,
ProfileTriggerResponse,
ProfileSettings,
ProfileSettingsResponse,
EntityProfileSettings,
ProfileSampleResult,
ProfileSamplesResponse,
ProfileRegenerateResponse,
+76 -15
View File
@@ -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<ProfileSettings> {
async getProfileSettings(): Promise<ProfileSettingsResponse> {
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<string, any>
).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<ProfileSettings> {
): Promise<ProfileSettingsResponse> {
const payloadKeys = Object.keys(settings || {});
this._captureEvent("update_profile_settings", [payloadKeys]);
await this._awaitIdentity();
const {
schema,
customInstructions,
enabled,
entityType = "user",
} = settings || {};
const body: Record<string, any> = {};
if (enabled !== undefined) {
body.enabled = enabled;
}
const entitySettings: Record<string, any> = {};
// The schema's property names are the customer's and must reach the API verbatim.
const { schema, ...rest } = settings;
const body: Record<string, any> = 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<ProfileJobResponse> {
async sampleProfiles(data?: {
limit?: number;
entityType?: ProfileEntityType;
}): Promise<ProfileJobResponse> {
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<ProfileJobResponse> {
async regenerateProfiles(data?: {
entityType?: ProfileEntityType;
}): Promise<ProfileJobResponse> {
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;
+34 -7
View File
@@ -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<ProfileSampleResult>;
}
/** @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<string, any> | 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<string, any> | 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<Record<ProfileEntityType, EntityProfileSettings>>;
capabilities?: Record<string, any>;
[key: string]: any;
}
export interface ProfileSampleResult {
@@ -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<string, { status: number; body: unknown }>();
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<string, { status: number; body: unknown }>();
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<string, { status: number; body: unknown }>();
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<string, { status: number; body: unknown }>();
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<string, { status: number; body: unknown }>();
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<string, { status: number; body: unknown }>();
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/");
});
+96 -36
View File
@@ -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
+59 -9
View File
@@ -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"})