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:
@@ -28,6 +28,8 @@ export type {
|
||||
ProfileResponse,
|
||||
ProfileTriggerResponse,
|
||||
ProfileSettings,
|
||||
ProfileSettingsResponse,
|
||||
EntityProfileSettings,
|
||||
ProfileSampleResult,
|
||||
ProfileSamplesResponse,
|
||||
ProfileRegenerateResponse,
|
||||
|
||||
+76
-15
@@ -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;
|
||||
|
||||
@@ -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/");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user