feat(profiles): user-only SDK for v1 (drop regenerate + agent)
Reduce the User Profiles SDK to the shipped v1 scope: user profiles only,
no full rebuild.
- Remove regenerate_profiles / regenerateProfiles (sync + async, Python + TS)
and the ProfileRegenerateResponse type. Full rebuild is gated off server-side
(409 not_yet_available).
- Drop the entity_type / entityType parameter from get_profile,
generate_profile, sample_profiles, and update_profile_settings; every path
and payload is scoped to "user". Narrow ProfileEntityType to "user".
- Update tests to the user-only surface.
Endpoint paths (/v2/profiles/jobs/, /v2/profiles/settings/,
/v2/entities/user/{id}/profile/) and the per-job Idempotency-Key are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,6 @@ export type {
|
||||
EntityProfileSettings,
|
||||
ProfileSampleResult,
|
||||
ProfileSamplesResponse,
|
||||
ProfileRegenerateResponse,
|
||||
} from "./mem0.types";
|
||||
|
||||
// Re-export enums as values (not type-only)
|
||||
|
||||
+15
-56
@@ -28,7 +28,6 @@ import {
|
||||
ProfileSettings,
|
||||
ProfileSettingsResponse,
|
||||
ProfileSamplesResponse,
|
||||
ProfileRegenerateResponse,
|
||||
} from "./mem0.types";
|
||||
import {
|
||||
captureClientEvent,
|
||||
@@ -772,21 +771,17 @@ export default class MemoryClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the memory profile for a single entity.
|
||||
* Get the memory profile for a single user.
|
||||
*
|
||||
* Branch on `status`, not on an empty `profile`: generation is asynchronous,
|
||||
* so a known entity without a profile yet is a normal response.
|
||||
* so a known user without a profile yet is a normal response.
|
||||
*/
|
||||
async getProfile(data: {
|
||||
entityId: string;
|
||||
entityType?: ProfileEntityType;
|
||||
}): Promise<ProfileResponse> {
|
||||
async getProfile(data: { entityId: string }): Promise<ProfileResponse> {
|
||||
this._captureEvent("get_profile", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const entityType = data.entityType ?? "user";
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}/v2/entities/${encodeURIComponent(entityType)}/${encodeURIComponent(data.entityId)}/profile/`,
|
||||
`${this.host}/v2/entities/user/${encodeURIComponent(data.entityId)}/profile/`,
|
||||
{
|
||||
headers: this.headers,
|
||||
},
|
||||
@@ -795,15 +790,14 @@ export default class MemoryClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate or refresh the profile for one entity, now.
|
||||
* Generate or refresh the profile for one user, now.
|
||||
*
|
||||
* Profiles are otherwise built once an entity crosses an internal message
|
||||
* threshold, so a new entity has none for its first few memories. Returns as
|
||||
* Profiles are otherwise built once a user crosses an internal message
|
||||
* threshold, so a new user has none for its first few memories. Returns as
|
||||
* soon as the work is queued: poll {@link getProfile} and branch on `status`.
|
||||
*/
|
||||
async generateProfile(data: {
|
||||
entityId: string;
|
||||
entityType?: ProfileEntityType;
|
||||
}): Promise<ProfileTriggerResponse> {
|
||||
this._captureEvent("generate_profile", []);
|
||||
await this._awaitIdentity();
|
||||
@@ -815,7 +809,7 @@ export default class MemoryClient {
|
||||
headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() },
|
||||
body: JSON.stringify({
|
||||
operation: "trigger",
|
||||
entity_type: data.entityType ?? "user",
|
||||
entity_type: "user",
|
||||
entity_id: data.entityId,
|
||||
}),
|
||||
},
|
||||
@@ -873,7 +867,7 @@ export default class MemoryClient {
|
||||
/**
|
||||
* Update profile settings. Only the fields you pass are written.
|
||||
*
|
||||
* `schema` and `customInstructions` are per entity type and are nested under
|
||||
* `schema` and `customInstructions` are per user and are nested under
|
||||
* `entities` for the API; only `enabled` is project-wide. Sending them flat
|
||||
* is rejected with `Unsupported settings`.
|
||||
*/
|
||||
@@ -884,12 +878,7 @@ export default class MemoryClient {
|
||||
this._captureEvent("update_profile_settings", [payloadKeys]);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const {
|
||||
schema,
|
||||
customInstructions,
|
||||
enabled,
|
||||
entityType = "user",
|
||||
} = settings || {};
|
||||
const { schema, customInstructions, enabled } = settings || {};
|
||||
|
||||
const body: Record<string, any> = {};
|
||||
if (enabled !== undefined) {
|
||||
@@ -905,7 +894,7 @@ export default class MemoryClient {
|
||||
entitySettings.custom_instructions = customInstructions;
|
||||
}
|
||||
if (Object.keys(entitySettings).length > 0) {
|
||||
body.entities = { [entityType]: entitySettings };
|
||||
body.entities = { user: entitySettings };
|
||||
}
|
||||
|
||||
const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, {
|
||||
@@ -917,15 +906,12 @@ export default class MemoryClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate profiles for a few real entities, to check a schema.
|
||||
* Generate profiles for a few real users, to check a schema.
|
||||
*
|
||||
* Real generations against real memories, and the results are kept: the
|
||||
* profiles are written to those entities and count toward usage.
|
||||
* profiles are written to those users and count toward usage.
|
||||
*/
|
||||
async sampleProfiles(data?: {
|
||||
limit?: number;
|
||||
entityType?: ProfileEntityType;
|
||||
}): Promise<ProfileJobResponse> {
|
||||
async sampleProfiles(data?: { limit?: number }): Promise<ProfileJobResponse> {
|
||||
this._captureEvent("sample_profiles", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
@@ -937,7 +923,7 @@ export default class MemoryClient {
|
||||
body: JSON.stringify({
|
||||
operation: "sample",
|
||||
// Required: the API refuses a job that does not name an entity kind.
|
||||
entity_type: data?.entityType ?? "user",
|
||||
entity_type: "user",
|
||||
...this._prepareParams({ limit: data?.limit }),
|
||||
}),
|
||||
},
|
||||
@@ -945,33 +931,6 @@ export default class MemoryClient {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the profile of every entity of one kind in the project.
|
||||
*
|
||||
* 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(data?: {
|
||||
entityType?: ProfileEntityType;
|
||||
}): Promise<ProfileJobResponse> {
|
||||
this._captureEvent("regenerate_profiles", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}${PROFILE_JOBS_PATH}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() },
|
||||
body: JSON.stringify({
|
||||
operation: "regenerate",
|
||||
entity_type: data?.entityType ?? "user",
|
||||
}),
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one generation job. Accepts the `statusUrl` from a create call, or a
|
||||
* bare job id. Prefer `statusUrl` so a route change needs no client update.
|
||||
|
||||
@@ -239,8 +239,8 @@ export interface GetMemoryExportPayload {
|
||||
|
||||
// ─── Profile Types ──────────────────────────────────────────
|
||||
|
||||
/** Entity kinds that can carry a profile. */
|
||||
export type ProfileEntityType = "user" | "agent";
|
||||
/** The entity kind that carries a profile. */
|
||||
export type ProfileEntityType = "user";
|
||||
|
||||
/**
|
||||
* `succeeded` is the only state in which `profile` is guaranteed to hold content.
|
||||
@@ -249,7 +249,11 @@ export type ProfileEntityType = "user" | "agent";
|
||||
* spelling is what a comparison has to match.
|
||||
*/
|
||||
export type ProfileStatus =
|
||||
"succeeded" | "pending" | "failed" | "not_enabled" | "insufficient_data";
|
||||
| "succeeded"
|
||||
| "pending"
|
||||
| "failed"
|
||||
| "not_enabled"
|
||||
| "insufficient_data";
|
||||
|
||||
export interface ProfileResponse {
|
||||
/** Shaped by the project's schema; keys are not camel-cased. */
|
||||
@@ -282,15 +286,13 @@ export interface ProfileJobResponse {
|
||||
/** @deprecated Use {@link ProfileJobResponse}. */
|
||||
export type ProfileTriggerResponse = ProfileJobResponse;
|
||||
|
||||
/** The settings to write. `schema` and `customInstructions` apply to one entity type. */
|
||||
/** The settings to write. `schema` and `customInstructions` apply to user profiles. */
|
||||
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. */
|
||||
@@ -322,9 +324,6 @@ export interface ProfileSampleResult {
|
||||
/** @deprecated Use {@link ProfileJobResponse}. */
|
||||
export type ProfileSamplesResponse = ProfileJobResponse;
|
||||
|
||||
/** @deprecated Use {@link ProfileJobResponse}. */
|
||||
export type ProfileRegenerateResponse = ProfileJobResponse;
|
||||
|
||||
/** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */
|
||||
export interface ProfileJobStatus {
|
||||
job: {
|
||||
|
||||
@@ -76,18 +76,18 @@ describe("MemoryClient - getProfile()", () => {
|
||||
expect(status).not.toBe("insufficientData");
|
||||
});
|
||||
|
||||
test("defaults to user and encodes the entity id", async () => {
|
||||
test("scopes to user and encodes the entity id", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/entities/agent/", {
|
||||
extra.set("/v2/entities/user/", {
|
||||
status: 200,
|
||||
body: { profile: {}, status: "pending", entity_type: "agent" },
|
||||
body: { profile: {}, status: "pending", entity_type: "user" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.getProfile({ entityId: "a/b", entityType: "agent" });
|
||||
await client.getProfile({ entityId: "a/b" });
|
||||
|
||||
const call = findFetchCall(mock, "/v2/entities/agent/a%2Fb/profile/");
|
||||
const call = findFetchCall(mock, "/v2/entities/user/a%2Fb/profile/");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -179,7 +179,7 @@ describe("MemoryClient - profile settings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("targets the entity type the caller named", async () => {
|
||||
test("scopes entity-level settings under user", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/settings/", {
|
||||
status: 200,
|
||||
@@ -190,13 +190,12 @@ describe("MemoryClient - profile settings", () => {
|
||||
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"]);
|
||||
expect(Object.keys(body.entities)).toEqual(["user"]);
|
||||
});
|
||||
|
||||
test("omits fields the caller did not set", async () => {
|
||||
@@ -251,7 +250,7 @@ describe("MemoryClient - profile settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => {
|
||||
describe("MemoryClient - sampleProfiles()", () => {
|
||||
test("sampleProfiles omits limit when unset", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v2/profiles/jobs/", {
|
||||
@@ -304,46 +303,4 @@ describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => {
|
||||
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/", {
|
||||
status: 202,
|
||||
body: {
|
||||
job_id: "job_3",
|
||||
status: "QUEUED",
|
||||
status_url: "/v2/profiles/jobs/job_3/",
|
||||
operation: "regenerate",
|
||||
entity_type: "user",
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
const result = await client.regenerateProfiles();
|
||||
|
||||
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/");
|
||||
});
|
||||
});
|
||||
|
||||
+56
-133
@@ -49,11 +49,10 @@ 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
|
||||
``schema`` and ``custom_instructions`` are per user and nest under
|
||||
``entities``; only ``enabled`` is project-wide. This mirrors what
|
||||
``get_profile_settings`` returns, so the two round-trip.
|
||||
|
||||
@@ -72,7 +71,7 @@ def _profile_settings_payload(
|
||||
entity_settings["custom_instructions"] = custom_instructions
|
||||
|
||||
if entity_settings:
|
||||
payload["entities"] = {entity_type: entity_settings}
|
||||
payload["entities"] = {"user": entity_settings}
|
||||
return payload
|
||||
|
||||
|
||||
@@ -719,15 +718,14 @@ class MemoryClient:
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
def get_profile(self, entity_id: str, entity_type: str = "user") -> Dict[str, Any]:
|
||||
"""Get the memory profile for a single entity.
|
||||
def get_profile(self, entity_id: str) -> Dict[str, Any]:
|
||||
"""Get the memory profile for a single user.
|
||||
|
||||
Branch on ``status``, not on an empty ``profile``: generation is
|
||||
asynchronous, so a known entity without a profile yet is a normal response.
|
||||
asynchronous, so a known user without a profile yet is a normal response.
|
||||
|
||||
Args:
|
||||
entity_id: The entity's id, as you supplied it on ``add`` (e.g. "alice").
|
||||
entity_type: Either "user" or "agent". Defaults to "user".
|
||||
entity_id: The user's id, as you supplied it on ``add`` (e.g. "alice").
|
||||
|
||||
Returns:
|
||||
Dict with ``profile``, ``status``, ``entity_type``, ``entity_id``,
|
||||
@@ -735,47 +733,43 @@ class MemoryClient:
|
||||
"succeeded", "pending", "failed", "not_enabled" or "insufficient_data".
|
||||
|
||||
Raises:
|
||||
ValidationError: If entity_type is not a supported entity kind.
|
||||
AuthenticationError: If authentication fails.
|
||||
NotFoundError: If no such entity exists in the project.
|
||||
NotFoundError: If no such user exists in the project.
|
||||
"""
|
||||
|
||||
response = self.client.get(
|
||||
f"/v2/entities/{_encode_path_segment(entity_type)}/{_encode_path_segment(entity_id)}/profile/"
|
||||
)
|
||||
response = self.client.get(f"/v2/entities/user/{_encode_path_segment(entity_id)}/profile/")
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.get_profile", self, {"entity_type": entity_type, "sync_type": "sync"})
|
||||
capture_client_event("client.get_profile", self, {"sync_type": "sync"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
def generate_profile(self, entity_id: str, entity_type: str = "user") -> Dict[str, Any]:
|
||||
"""Generate or refresh the profile for a single entity, now.
|
||||
def generate_profile(self, entity_id: str) -> Dict[str, Any]:
|
||||
"""Generate or refresh the profile for a single user, now.
|
||||
|
||||
Profiles are otherwise built once an entity crosses an internal message
|
||||
threshold, so a new entity has none for its first few memories. Returns as
|
||||
Profiles are otherwise built once a user crosses an internal message
|
||||
threshold, so a new user has none for its first few memories. Returns as
|
||||
soon as the work is queued: poll :meth:`get_profile` and branch on ``status``.
|
||||
|
||||
Args:
|
||||
entity_id: The entity's id, as you supplied it on ``add``.
|
||||
entity_type: Either "user" or "agent". Defaults to "user".
|
||||
entity_id: The user's id, as you supplied it on ``add``.
|
||||
|
||||
Returns:
|
||||
Dict containing ``profile_id``, ``entity_type``, ``entity_id`` and
|
||||
``status``.
|
||||
|
||||
Raises:
|
||||
ValidationError: If entity_type is unsupported or profiles are not
|
||||
enabled and configured for the project.
|
||||
NotFoundError: If no such entity exists in the project.
|
||||
ValidationError: If profiles are not enabled and configured for the
|
||||
project.
|
||||
NotFoundError: If no such user exists in the project.
|
||||
"""
|
||||
|
||||
response = self.client.post(
|
||||
PROFILE_JOBS_PATH,
|
||||
json={"operation": "trigger", "entity_type": entity_type, "entity_id": entity_id},
|
||||
json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id},
|
||||
headers={"Idempotency-Key": uuid.uuid4().hex},
|
||||
)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.generate_profile", self, {"entity_type": entity_type, "sync_type": "sync"})
|
||||
capture_client_event("client.generate_profile", self, {"sync_type": "sync"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
@@ -799,7 +793,6 @@ 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.
|
||||
|
||||
@@ -808,11 +801,9 @@ class MemoryClient:
|
||||
Args:
|
||||
enabled: Turn profile generation on or off. Project-wide.
|
||||
schema: JSON Schema for the profile. Every property needs a
|
||||
``description``. Applies to ``entity_type``.
|
||||
``description``. Applies to user profiles.
|
||||
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".
|
||||
Applies to user profiles.
|
||||
|
||||
Returns:
|
||||
Dict with the settings as stored after the update, in the same
|
||||
@@ -822,26 +813,25 @@ class MemoryClient:
|
||||
ValidationError: If the schema is not a valid profile schema.
|
||||
"""
|
||||
|
||||
payload = _profile_settings_payload(enabled, schema, custom_instructions, entity_type)
|
||||
payload = _profile_settings_payload(enabled, schema, custom_instructions)
|
||||
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()), "entity_type": entity_type, "sync_type": "sync"},
|
||||
{"keys": list(payload.keys()), "sync_type": "sync"},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
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.
|
||||
def sample_profiles(self, limit: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Generate profiles for a few real users, to check a schema.
|
||||
|
||||
Real generations against real memories, and the results are kept. The
|
||||
profiles are written to those entities and count toward usage.
|
||||
profiles are written to those users and count toward usage.
|
||||
|
||||
Args:
|
||||
limit: How many entities to sample, 1-10. Defaults to the server value.
|
||||
entity_type: Which entity kind to sample. Defaults to "user".
|
||||
limit: How many users to sample, 1-10. Defaults to the server value.
|
||||
|
||||
Returns:
|
||||
Dict containing ``job_id``, ``status``, ``status_url``, ``sampled``
|
||||
@@ -855,42 +845,14 @@ class MemoryClient:
|
||||
|
||||
payload = self._prepare_params({"limit": limit})
|
||||
payload["operation"] = "sample"
|
||||
payload["entity_type"] = entity_type
|
||||
payload["entity_type"] = "user"
|
||||
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, {"entity_type": entity_type, "sync_type": "sync"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
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 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``.
|
||||
|
||||
Raises:
|
||||
ValidationError: If profiles are not enabled and configured.
|
||||
RateLimitError: If a regenerate already ran for this project recently.
|
||||
"""
|
||||
|
||||
response = self.client.post(
|
||||
PROFILE_JOBS_PATH,
|
||||
json={"operation": "regenerate", "entity_type": entity_type},
|
||||
headers={"Idempotency-Key": uuid.uuid4().hex},
|
||||
)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.regenerate_profiles", self, {"entity_type": entity_type, "sync_type": "sync"})
|
||||
capture_client_event("client.sample_profiles", self, {"sync_type": "sync"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
@@ -1820,15 +1782,14 @@ class AsyncMemoryClient:
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
async def get_profile(self, entity_id: str, entity_type: str = "user") -> Dict[str, Any]:
|
||||
"""Get the memory profile for a single entity.
|
||||
async def get_profile(self, entity_id: str) -> Dict[str, Any]:
|
||||
"""Get the memory profile for a single user.
|
||||
|
||||
Branch on ``status``, not on an empty ``profile``: generation is
|
||||
asynchronous, so a known entity without a profile yet is a normal response.
|
||||
asynchronous, so a known user without a profile yet is a normal response.
|
||||
|
||||
Args:
|
||||
entity_id: The entity's id, as you supplied it on ``add`` (e.g. "alice").
|
||||
entity_type: Either "user" or "agent". Defaults to "user".
|
||||
entity_id: The user's id, as you supplied it on ``add`` (e.g. "alice").
|
||||
|
||||
Returns:
|
||||
Dict with ``profile``, ``status``, ``entity_type``, ``entity_id``,
|
||||
@@ -1836,47 +1797,43 @@ class AsyncMemoryClient:
|
||||
"succeeded", "pending", "failed", "not_enabled" or "insufficient_data".
|
||||
|
||||
Raises:
|
||||
ValidationError: If entity_type is not a supported entity kind.
|
||||
AuthenticationError: If authentication fails.
|
||||
NotFoundError: If no such entity exists in the project.
|
||||
NotFoundError: If no such user exists in the project.
|
||||
"""
|
||||
|
||||
response = await self.async_client.get(
|
||||
f"/v2/entities/{_encode_path_segment(entity_type)}/{_encode_path_segment(entity_id)}/profile/"
|
||||
)
|
||||
response = await self.async_client.get(f"/v2/entities/user/{_encode_path_segment(entity_id)}/profile/")
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.get_profile", self, {"entity_type": entity_type, "sync_type": "async"})
|
||||
capture_client_event("client.get_profile", self, {"sync_type": "async"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
async def generate_profile(self, entity_id: str, entity_type: str = "user") -> Dict[str, Any]:
|
||||
"""Generate or refresh the profile for a single entity, now.
|
||||
async def generate_profile(self, entity_id: str) -> Dict[str, Any]:
|
||||
"""Generate or refresh the profile for a single user, now.
|
||||
|
||||
Profiles are otherwise built once an entity crosses an internal message
|
||||
threshold, so a new entity has none for its first few memories. Returns as
|
||||
Profiles are otherwise built once a user crosses an internal message
|
||||
threshold, so a new user has none for its first few memories. Returns as
|
||||
soon as the work is queued: poll :meth:`get_profile` and branch on ``status``.
|
||||
|
||||
Args:
|
||||
entity_id: The entity's id, as you supplied it on ``add``.
|
||||
entity_type: Either "user" or "agent". Defaults to "user".
|
||||
entity_id: The user's id, as you supplied it on ``add``.
|
||||
|
||||
Returns:
|
||||
Dict containing ``profile_id``, ``entity_type``, ``entity_id`` and
|
||||
``status``.
|
||||
|
||||
Raises:
|
||||
ValidationError: If entity_type is unsupported or profiles are not
|
||||
enabled and configured for the project.
|
||||
NotFoundError: If no such entity exists in the project.
|
||||
ValidationError: If profiles are not enabled and configured for the
|
||||
project.
|
||||
NotFoundError: If no such user exists in the project.
|
||||
"""
|
||||
|
||||
response = await self.async_client.post(
|
||||
PROFILE_JOBS_PATH,
|
||||
json={"operation": "trigger", "entity_type": entity_type, "entity_id": entity_id},
|
||||
json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id},
|
||||
headers={"Idempotency-Key": uuid.uuid4().hex},
|
||||
)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.generate_profile", self, {"entity_type": entity_type, "sync_type": "async"})
|
||||
capture_client_event("client.generate_profile", self, {"sync_type": "async"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
@@ -1900,7 +1857,6 @@ 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.
|
||||
|
||||
@@ -1909,11 +1865,9 @@ class AsyncMemoryClient:
|
||||
Args:
|
||||
enabled: Turn profile generation on or off. Project-wide.
|
||||
schema: JSON Schema for the profile. Every property needs a
|
||||
``description``. Applies to ``entity_type``.
|
||||
``description``. Applies to user profiles.
|
||||
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".
|
||||
Applies to user profiles.
|
||||
|
||||
Returns:
|
||||
Dict with the settings as stored after the update, in the same
|
||||
@@ -1923,26 +1877,25 @@ class AsyncMemoryClient:
|
||||
ValidationError: If the schema is not a valid profile schema.
|
||||
"""
|
||||
|
||||
payload = _profile_settings_payload(enabled, schema, custom_instructions, entity_type)
|
||||
payload = _profile_settings_payload(enabled, schema, custom_instructions)
|
||||
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()), "entity_type": entity_type, "sync_type": "async"},
|
||||
{"keys": list(payload.keys()), "sync_type": "async"},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
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.
|
||||
async def sample_profiles(self, limit: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Generate profiles for a few real users, to check a schema.
|
||||
|
||||
Real generations against real memories, and the results are kept. The
|
||||
profiles are written to those entities and count toward usage.
|
||||
profiles are written to those users and count toward usage.
|
||||
|
||||
Args:
|
||||
limit: How many entities to sample, 1-10. Defaults to the server value.
|
||||
entity_type: Which entity kind to sample. Defaults to "user".
|
||||
limit: How many users to sample, 1-10. Defaults to the server value.
|
||||
|
||||
Returns:
|
||||
Dict containing ``job_id``, ``status``, ``status_url``, ``sampled``
|
||||
@@ -1956,44 +1909,14 @@ class AsyncMemoryClient:
|
||||
|
||||
payload = self._prepare_params({"limit": limit})
|
||||
payload["operation"] = "sample"
|
||||
payload["entity_type"] = entity_type
|
||||
payload["entity_type"] = "user"
|
||||
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, {"entity_type": entity_type, "sync_type": "async"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
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 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``.
|
||||
|
||||
Raises:
|
||||
ValidationError: If profiles are not enabled and configured.
|
||||
RateLimitError: If a regenerate already ran for this project recently.
|
||||
"""
|
||||
|
||||
response = await self.async_client.post(
|
||||
PROFILE_JOBS_PATH,
|
||||
json={"operation": "regenerate", "entity_type": entity_type},
|
||||
headers={"Idempotency-Key": uuid.uuid4().hex},
|
||||
)
|
||||
response.raise_for_status()
|
||||
capture_client_event(
|
||||
"client.regenerate_profiles", self, {"entity_type": entity_type, "sync_type": "async"}
|
||||
)
|
||||
capture_client_event("client.sample_profiles", self, {"sync_type": "async"})
|
||||
return response.json()
|
||||
|
||||
@api_error_handler
|
||||
|
||||
@@ -57,13 +57,6 @@ class TestGetProfile:
|
||||
|
||||
mock_memory_client.client.get.assert_called_once_with("/v2/entities/user/alice/profile/")
|
||||
|
||||
def test_supports_agents(self, mock_memory_client):
|
||||
mock_memory_client.client.get.return_value = _mock_response({"profile": {}, "status": "pending"})
|
||||
|
||||
mock_memory_client.get_profile("support-bot", entity_type="agent")
|
||||
|
||||
mock_memory_client.client.get.assert_called_once_with("/v2/entities/agent/support-bot/profile/")
|
||||
|
||||
def test_encodes_path_segments(self, mock_memory_client):
|
||||
"""An id with a slash must not open a new path segment."""
|
||||
mock_memory_client.client.get.return_value = _mock_response({"profile": {}, "status": "pending"})
|
||||
@@ -98,16 +91,6 @@ class TestGenerateProfile:
|
||||
{"operation": "trigger", "entity_type": "user", "entity_id": "alice"},
|
||||
)
|
||||
|
||||
def test_agent_entity_type(self, mock_memory_client):
|
||||
mock_memory_client.client.post.return_value = _mock_response({"profile_id": "p_2", "status": "PENDING"})
|
||||
|
||||
mock_memory_client.generate_profile("support-bot", entity_type="agent")
|
||||
|
||||
_assert_job_call(
|
||||
mock_memory_client.client.post,
|
||||
{"operation": "trigger", "entity_type": "agent", "entity_id": "support-bot"},
|
||||
)
|
||||
|
||||
|
||||
class TestProfileSettings:
|
||||
def test_get_reads_v2(self, mock_memory_client):
|
||||
@@ -145,14 +128,14 @@ class TestProfileSettings:
|
||||
assert set(kwargs["json"]) == {"enabled", "entities"}
|
||||
assert "schema" not in kwargs["json"]
|
||||
|
||||
def test_update_targets_the_named_entity_type(self, mock_memory_client):
|
||||
def test_update_targets_the_user_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")
|
||||
mock_memory_client.update_profile_settings(schema=schema)
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert kwargs["json"] == {"entities": {"agent": {"schema": schema}}}
|
||||
assert kwargs["json"] == {"entities": {"user": {"schema": schema}}}
|
||||
|
||||
def test_update_passes_schema_verbatim(self, mock_memory_client):
|
||||
schema = {
|
||||
@@ -178,7 +161,7 @@ class TestProfileSettings:
|
||||
)
|
||||
|
||||
|
||||
class TestSampleAndRegenerate:
|
||||
class TestSampleProfiles:
|
||||
def test_sample_without_limit(self, mock_memory_client):
|
||||
mock_memory_client.client.post.return_value = _mock_response({"sampled": 5, "entity_ids": []})
|
||||
|
||||
@@ -196,23 +179,6 @@ class TestSampleAndRegenerate:
|
||||
{"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(
|
||||
{"status": "accepted", "project_id": "proj_abc", "existing_profile_count": 12}
|
||||
)
|
||||
|
||||
mock_memory_client.regenerate_profiles()
|
||||
|
||||
_assert_job_call(mock_memory_client.client.post, {"operation": "regenerate", "entity_type": "user"})
|
||||
|
||||
|
||||
class TestAsyncClientParity:
|
||||
"""The async client must speak the same wire protocol as the sync one."""
|
||||
@@ -246,11 +212,11 @@ class TestAsyncClientParity:
|
||||
def test_generate_profile(self, async_client):
|
||||
async_client.async_client.post = AsyncMock(return_value=_mock_response({"profile_id": "p_1"}))
|
||||
|
||||
asyncio.run(async_client.generate_profile("alice", entity_type="agent"))
|
||||
asyncio.run(async_client.generate_profile("alice"))
|
||||
|
||||
_assert_job_call(
|
||||
async_client.async_client.post,
|
||||
{"operation": "trigger", "entity_type": "agent", "entity_id": "alice"},
|
||||
{"operation": "trigger", "entity_type": "user", "entity_id": "alice"},
|
||||
)
|
||||
|
||||
def test_update_settings_partial(self, async_client):
|
||||
@@ -274,10 +240,3 @@ class TestAsyncClientParity:
|
||||
"/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", "entity_type": "user"})
|
||||
|
||||
Reference in New Issue
Block a user