diff --git a/integrations/deepseek-plugin/src/index.ts b/integrations/deepseek-plugin/src/index.ts index 2d24b498a..3ac86f4b0 100644 --- a/integrations/deepseek-plugin/src/index.ts +++ b/integrations/deepseek-plugin/src/index.ts @@ -26,8 +26,9 @@ export const name = "mem0"; export const inject = ["tools", "systemPrompt"]; // Tags writes so Mem0's backend attributes them to this integration in -// telemetry. The backend's KNOWN_EVENT_SOURCES allowlist recognizes this value; -// anything outside it buckets into "OTHERS". +// telemetry. Values outside the backend's KNOWN_EVENT_SOURCES allowlist bucket +// into "OTHERS"; this one is added by mem0ai/platform#3602 and reads as OTHERS +// until that ships. const SOURCE = "DEEPSEEK_HARNESS"; const DEFAULT_SEARCH_LIMIT = 10; diff --git a/integrations/pi-agent-plugin/src/attribution.test.ts b/integrations/pi-agent-plugin/src/attribution.test.ts index 79d938c62..f90d28da3 100644 --- a/integrations/pi-agent-plugin/src/attribution.test.ts +++ b/integrations/pi-agent-plugin/src/attribution.test.ts @@ -51,3 +51,30 @@ describe("applySurfaceHeaders", () => { expect(headers["X-Mem0-Client"].length).toBeLessThanOrEqual(200); }); }); + +describe("client stack bounding", () => { + it("keeps our own entry when the caller already filled the stack", () => { + // The defect: pushing then trimming to four dropped exactly the entry this + // function exists to add, so we vanished from our own stack. + const mem0 = client({ "X-Mem0-Client": "a/1, b/2, c/3, d/4" }); + applySurfaceHeaders(mem0); + const stack = (mem0 as unknown as { headers: Record }).headers["X-Mem0-Client"]; + + expect(stack).toMatch(/mem0-pi-agent\//); + expect(stack.split(",").length).toBeLessThanOrEqual(4); + }); + + it("drops whole entries at the character cap, never a fragment", () => { + const long = `${"n".repeat(90)}/1.0, ${"m".repeat(90)}/1.0, ${"o".repeat(90)}/1.0`; + const mem0 = client({ "X-Mem0-Client": long }); + applySurfaceHeaders(mem0); + const stack = (mem0 as unknown as { headers: Record }).headers["X-Mem0-Client"]; + + expect(stack.length).toBeLessThanOrEqual(200); + expect(stack.endsWith("/0.0.0") || /mem0-pi-agent\/[\w.\-]+$/.test(stack)).toBe(true); + // Every surviving entry is whole: name/version, no severed tail. + for (const entry of stack.split(",")) { + expect(entry.trim()).toMatch(/^[^/]+\/[^/]+$/); + } + }); +}); diff --git a/integrations/pi-agent-plugin/src/attribution.ts b/integrations/pi-agent-plugin/src/attribution.ts index 7ab3ea6d6..2cbc9a73e 100644 --- a/integrations/pi-agent-plugin/src/attribution.ts +++ b/integrations/pi-agent-plugin/src/attribution.ts @@ -18,7 +18,26 @@ const PLUGIN_VERSION = (() => { })(); const MAX_STACK_ENTRIES = 4; -const MAX_HEADER_CHARS = 200; +const MAX_STACK_CHARS = 200; + +/** + * Append our own entry and bound the result, dropping WHOLE entries. + * + * Neither cap cuts characters: slicing the joined string severs an identifier + * and leaves a fragment the platform parses as a real client name. And the + * reserved slot is ours, since it is the only entry this layer can vouch for. + */ +function boundedStack(callerEntries: string[], own: string): string { + const kept: string[] = []; + let budget = MAX_STACK_CHARS - own.length; + for (const entry of callerEntries.slice(0, MAX_STACK_ENTRIES - 1)) { + const cost = entry.length + ", ".length; + if (cost > budget) break; + budget -= cost; + kept.push(entry); + } + return [...kept, own].join(", "); +} /** * Stamp surface identity onto the shared client, once, at construction. @@ -42,9 +61,5 @@ export function applySurfaceHeaders(client: MemoryClient): void { .split(",") .map((part) => part.trim()) .filter(Boolean); - existing.push(`mem0-pi-agent/${PLUGIN_VERSION}`); - headers["X-Mem0-Client"] = existing - .slice(0, MAX_STACK_ENTRIES) - .join(", ") - .slice(0, MAX_HEADER_CHARS); + headers["X-Mem0-Client"] = boundedStack(existing, `mem0-pi-agent/${PLUGIN_VERSION}`); } diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index ddea270a2..33e03f0c2 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -103,6 +103,30 @@ declare const __MEM0_SDK_VERSION__: string | undefined; const SDK_VERSION = typeof __MEM0_SDK_VERSION__ !== "undefined" ? __MEM0_SDK_VERSION__ : "dev"; +const MAX_STACK_ENTRIES = 4; +const MAX_STACK_CHARS = 200; + +/** + * Append our own entry and bound the result, dropping WHOLE entries. + * + * Neither cap cuts characters: slicing the joined string severs an identifier + * and leaves a fragment the platform parses as a real client name. And the + * reserved slot is ours. Pushing first and then trimming to four dropped exactly + * the entry this exists to add whenever a caller already sent four, so we + * vanished from our own stack while every caller claim survived. + */ +function boundedStack(callerEntries: string[], own: string): string { + const kept: string[] = []; + let budget = MAX_STACK_CHARS - own.length; + for (const entry of callerEntries.slice(0, MAX_STACK_ENTRIES - 1)) { + const cost = entry.length + ", ".length; + if (cost > budget) break; + budget -= cost; + kept.push(entry); + } + return [...kept, own].join(", "); +} + /** * Surface-identity headers. * @@ -121,10 +145,8 @@ function surfaceHeaders(): Record { .map((part) => part.trim()) .filter(Boolean) : []; - entries.push(`mem0-js/${SDK_VERSION}`); - const headers: Record = { - "X-Mem0-Client": entries.slice(0, 4).join(", ").slice(0, 200), + "X-Mem0-Client": boundedStack(entries, `mem0-js/${SDK_VERSION}`), }; const source = (env.MEM0_SOURCE ?? "").trim(); if (source) headers["X-Mem0-Source"] = source; diff --git a/mem0/client/main.py b/mem0/client/main.py index abdf61bca..65f2212b0 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -103,8 +103,7 @@ def _apply_client_headers(client: Any, api_key: str, user_id: str) -> None: outer_stack = existing.get("X-Mem0-Client") if outer_stack: entries = [part.strip() for part in str(outer_stack).split(",") if part.strip()] - entries.append(f"mem0-python/{_sdk_version()}") - mine["X-Mem0-Client"] = _bounded_stack(entries) + mine["X-Mem0-Client"] = _bounded_stack(entries, f"mem0-python/{_sdk_version()}") for name, value in mine.items(): if name in ("X-Mem0-Source", "X-Application") and existing.get(name): @@ -112,19 +111,29 @@ def _apply_client_headers(client: Any, api_key: str, user_id: str) -> None: existing[name] = value -def _bounded_stack(entries) -> str: - """Join stack entries within the cap, dropping whole entries not characters. +MAX_STACK_ENTRIES = 4 +MAX_STACK_CHARS = 200 - A blunt slice cut mid-identifier and left a fragment that parses as a real - client name. + +def _bounded_stack(caller_entries, own: str) -> str: + """Append our own entry and bound the result, dropping WHOLE entries. + + Two rules, and the second is the one that was wrong. Neither cap cuts + characters: a blunt slice severs an identifier and leaves a fragment that + parses as a real client name. And the reserved slot is OURS. Appending first + and then trimming to four dropped exactly the entry this function exists to + add, every time a caller already sent four, so the SDK vanished from its own + stack while the caller's claims all survived. """ - out = [] - for entry in list(entries)[:4]: - candidate = ", ".join(out + [entry]) - if len(candidate) > 200: + kept = [] + budget = MAX_STACK_CHARS - len(own) + for entry in list(caller_entries)[: MAX_STACK_ENTRIES - 1]: + cost = len(entry) + len(", ") + if cost > budget: break - out.append(entry) - return ", ".join(out) + budget -= cost + kept.append(entry) + return ", ".join(kept + [own]) def _client_headers(api_key: str, user_id: str) -> Dict[str, str]: