From 3a72dfdc52303efa8d105148685d9c09b95e250f Mon Sep 17 00:00:00 2001 From: Saket Aryan Date: Thu, 17 Sep 2026 19:29:13 +0530 Subject: [PATCH] fix(integrations): reserve our own slot in the client stack, and drop whole entries Two review findings on this PR. All three client-stack implementations appended our entry and then trimmed to four, so whenever a caller already sent four entries the one dropped was exactly the one the function exists to add. We vanished from our own stack while every caller claim survived. The character cap was worse: slicing the joined string severs an identifier, and the platform parses the fragment as a real client, so a truncated tail arrives as a client literally named "me". Both caps now drop whole entries and the reserved slot is ours, in the Python SDK, the TypeScript SDK and pi-agent. mcp-server has the same fix on the platform branch. The deepseek comment claimed the backend's allowlist recognizes DEEPSEEK_HARNESS. This PR introduced that wording, replacing a neutral one. It is not true until mem0ai/platform#3602 ships, so it now states the dependency. Two pi-agent tests: our entry survives a full caller stack, and every surviving entry is whole rather than a severed tail. Python side verified directly, a 4-entry caller stack keeps mem0-python and long entries are dropped whole. 312 passed 8 skipped, pi-agent 96, bundles clean, TS SDK builds. Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb --- integrations/deepseek-plugin/src/index.ts | 5 +-- .../pi-agent-plugin/src/attribution.test.ts | 27 +++++++++++++++ .../pi-agent-plugin/src/attribution.ts | 27 +++++++++++---- mem0-ts/src/client/mem0.ts | 28 ++++++++++++++-- mem0/client/main.py | 33 ++++++++++++------- 5 files changed, 97 insertions(+), 23 deletions(-) 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]: