From 2ed0d7d04f45bec92f49aa4c3c0d0023630d65b7 Mon Sep 17 00:00:00 2001
From: mateaix <57164338+mateaix@users.noreply.github.com>
Date: Sun, 26 Jul 2026 11:07:04 +0800
Subject: [PATCH] feat(memory): thread ownerKey through post-turn sync for
per-owner writes
---
.../api/memory/PluginMemoryProvider.java | 22 +++++++
.../vip/mate/plugin/mem0/Mem0Provider.java | 36 +++++++-----
.../mate/plugin/mem0/Mem0ProviderTest.java | 37 ++++++++++--
.../lifecycle/MemoryLifecycleMediator.java | 2 +-
.../vip/mate/memory/spi/MemoryManager.java | 12 +++-
.../vip/mate/memory/spi/MemoryProvider.java | 18 ++++++
.../decorator/MemoryProviderDecorator.java | 3 +
.../spi/decorator/MetricsMemoryProvider.java | 7 ++-
.../decorator/RetryableMemoryProvider.java | 7 ++-
.../plugin/bridge/PluginMemoryBridge.java | 8 +++
.../src/main/resources/docs/en/memory.md | 6 +-
.../src/main/resources/docs/zh/memory.md | 6 +-
.../MemoryManagerPluginPrefetchTest.java | 58 +++++++++++++++++++
.../lifecycle/LifecycleFlagGuardTest.java | 6 +-
.../MemoryLifecycleMediatorTest.java | 6 +-
.../plugin/bridge/PluginMemoryBridgeTest.java | 48 +++++++++++++++
16 files changed, 245 insertions(+), 37 deletions(-)
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java
index 128c185a..b4b47833 100644
--- a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/memory/PluginMemoryProvider.java
@@ -80,6 +80,28 @@ public interface PluginMemoryProvider {
String userMessage, String assistantReply) {
}
+ /**
+ * Post-turn sync with per-owner isolation. Called by the platform with the
+ * same {@code ownerKey} that was resolved for this turn's prefetch, so
+ * providers can persist the turn under the same per-user identifier they
+ * recall by.
+ *
+ * Default implementation degrades to the four-arg variant, dropping the
+ * owner key. External providers that isolate memory per end-user should
+ * override this so that written memories stay reachable by owner-scoped
+ * recall.
+ *
+ * @param agentId the agent ID
+ * @param conversationId the conversation ID
+ * @param userMessage user's message text
+ * @param assistantReply assistant's reply text
+ * @param ownerKey memory owner key (e.g. {@code "user:42"}), or null if unknown
+ */
+ default void syncTurn(Long agentId, String conversationId,
+ String userMessage, String assistantReply, String ownerKey) {
+ syncTurn(agentId, conversationId, userMessage, assistantReply);
+ }
+
/**
* Tool beans this provider wants to expose to the agent.
*/
diff --git a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java
index 95ea8df7..fc42efd6 100644
--- a/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java
+++ b/mateclaw-plugin-mem0/src/main/java/vip/mate/plugin/mem0/Mem0Provider.java
@@ -19,9 +19,13 @@ import java.util.concurrent.Executors;
* and {@code ownerKey} is non-blank, calls {@code POST /memories/search/}
* and returns a {@code [Mem0 Recall]} block. Returns "" on any failure
* or when disabled.
- *
{@code syncTurn} — when {@code syncEnabled} and {@code ownerKey} is
- * non-blank, asynchronously pushes the turn to {@code POST /memories/}.
- * Failures are logged and swallowed; never blocks the response path.
+ *
{@code syncTurn(agentId, conversationId, messages, ownerKey)} — when
+ * {@code syncEnabled} and {@code ownerKey} is non-blank, asynchronously
+ * pushes the turn to {@code POST /memories/} under {@code user_id =
+ * ownerKey}, the same identifier prefetch recalls by. Failures are
+ * logged and swallowed; never blocks the response path. The four-arg
+ * variant (no ownerKey) skips — writing under any other identifier
+ * would produce memories that owner-scoped recall can never surface.
*
{@code getToolBeans} — empty (no agent-facing tools in v1)
*
*
@@ -119,19 +123,20 @@ class Mem0Provider implements PluginMemoryProvider {
@Override
public void syncTurn(Long agentId, String conversationId,
String userMessage, String assistantReply) {
+ // Four-arg variant: no owner key → skip. Mem0 keys memories by user_id;
+ // writing under any fallback identifier (e.g. agentId) would store
+ // memories that owner-scoped prefetch can never recall.
+ }
+
+ @Override
+ public void syncTurn(Long agentId, String conversationId,
+ String userMessage, String assistantReply, String ownerKey) {
if (!config.syncEnabled()) {
return;
}
- // ownerKey is NOT available in the two-arg syncTurn signature
- // (PlatformMemoryProvider only passes agentId + conversationId + messages).
- // We push the turn using agentId as the user_id fallback — this is
- // weaker isolation than prefetch (which has ownerKey), but better than
- // dropping the turn. If users need strict per-owner sync, configure
- // syncEnabled=false and rely on prefetch-only recall.
- // NOTE: this is a known v1 limitation; a future SPI extension would
- // pass ownerKey into syncTurn as well.
- String userId = agentId == null ? null : agentId.toString();
- if (userId == null || userId.isBlank()) {
+ if (ownerKey == null || ownerKey.isBlank()) {
+ // Same guard as prefetch: Mem0 requires user_id; without the owner
+ // key the write would break per-owner isolation.
return;
}
if ((userMessage == null || userMessage.isBlank())
@@ -140,10 +145,11 @@ class Mem0Provider implements PluginMemoryProvider {
}
CompletableFuture.runAsync(() -> {
try {
- client.addMemories(userId, agentId == null ? null : agentId.toString(),
+ client.addMemories(ownerKey, agentId == null ? null : agentId.toString(),
conversationId, userMessage, assistantReply);
} catch (Exception e) {
- log.debug("[Mem0] syncTurn failed for agent={}: {}", agentId, e.getMessage());
+ log.debug("[Mem0] syncTurn failed for agent={} owner={}: {}",
+ agentId, ownerKey, e.getMessage());
}
}, async);
}
diff --git a/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java
index dceedf42..0a84d40d 100644
--- a/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java
+++ b/mateclaw-plugin-mem0/src/test/java/vip/mate/plugin/mem0/Mem0ProviderTest.java
@@ -13,6 +13,7 @@ import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
@@ -22,11 +23,13 @@ class Mem0ProviderTest {
private Mem0Provider provider;
private final AtomicInteger addCount = new AtomicInteger();
private final AtomicInteger searchCount = new AtomicInteger();
+ private final AtomicReference lastAddBody = new AtomicReference<>();
@BeforeEach
void setUp() throws IOException {
addCount.set(0);
searchCount.set(0);
+ lastAddBody.set(null);
HttpHandler handler = this::handle;
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", handler);
@@ -44,13 +47,15 @@ class Mem0ProviderTest {
}
private void handle(HttpExchange exchange) throws IOException {
+ String body;
try (InputStream in = exchange.getRequestBody()) {
- in.readAllBytes(); // drain
+ body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
String path = exchange.getRequestURI().getPath();
byte[] resp;
if ("/memories/".equals(path)) {
addCount.incrementAndGet();
+ lastAddBody.set(body);
resp = "{\"results\":[]}".getBytes(StandardCharsets.UTF_8);
} else if ("/memories/search/".equals(path)) {
searchCount.incrementAndGet();
@@ -137,8 +142,8 @@ class Mem0ProviderTest {
}
@Test
- void syncTurn_pushesAsynchronouslyWithoutBlocking() throws Exception {
- provider.syncTurn(1L, "conv-1", "hello", "world");
+ void syncTurn_pushesAsynchronouslyWithOwnerKeyAsUserId() throws Exception {
+ provider.syncTurn(1L, "conv-1", "hello", "world", "user:42");
// Wait briefly for the async executor to fire the POST.
long deadline = System.currentTimeMillis() + 2000;
@@ -146,11 +151,31 @@ class Mem0ProviderTest {
Thread.sleep(20);
}
assertThat(addCount.get()).isEqualTo(1);
+ // The write must land under the same user_id that prefetch recalls by.
+ assertThat(lastAddBody.get()).contains("\"user_id\":\"user:42\"");
+ assertThat(lastAddBody.get()).contains("\"agent_id\":\"1\"");
+ }
+
+ @Test
+ void fourArgSyncTurn_skipsBecauseNoOwnerKey() throws Exception {
+ // Without ownerKey, a write would be keyed by an identifier that
+ // owner-scoped prefetch never queries; the provider must skip.
+ provider.syncTurn(1L, "conv-1", "hello", "world");
+ Thread.sleep(200); // give async a chance to (not) fire
+ assertThat(addCount.get()).isZero();
+ }
+
+ @Test
+ void syncTurn_skipsWhenOwnerKeyBlank() throws Exception {
+ provider.syncTurn(1L, "conv-1", "hello", "world", "");
+ provider.syncTurn(1L, "conv-1", "hello", "world", null);
+ Thread.sleep(200);
+ assertThat(addCount.get()).isZero();
}
@Test
void syncTurn_skipsWhenBothMessagesBlank() throws Exception {
- provider.syncTurn(1L, "conv-1", " ", "");
+ provider.syncTurn(1L, "conv-1", " ", "", "user:42");
Thread.sleep(200); // give async a chance to (not) fire
assertThat(addCount.get()).isZero();
}
@@ -165,7 +190,7 @@ class Mem0ProviderTest {
server.createContext("/", ex -> { ex.sendResponseHeaders(200, 0); ex.close(); });
// Note: client still points at the old port → connection refused.
- provider.syncTurn(1L, "conv-1", "hi", "there");
+ provider.syncTurn(1L, "conv-1", "hi", "there", "user:42");
Thread.sleep(500);
// No exception thrown; nothing to assert beyond "test didn't blow up".
}
@@ -177,7 +202,7 @@ class Mem0ProviderTest {
"http://127.0.0.1:" + server.getAddress().getPort(),
null, true, false, 3, 3000);
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
- p.syncTurn(1L, "conv-1", "hi", "there");
+ p.syncTurn(1L, "conv-1", "hi", "there", "user:42");
Thread.sleep(200);
assertThat(addCount.get()).isZero();
}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java
index f52f28a6..c9625b09 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java
@@ -61,7 +61,7 @@ public class MemoryLifecycleMediator {
public void afterLlmCall(TurnContext ctx, String assistantReply) {
try {
memoryManager.syncAll(ctx.agentId(), ctx.conversationId(),
- ctx.userQuery(), assistantReply);
+ ctx.userQuery(), assistantReply, ctx.ownerKey());
events.publishEvent(new TurnCompletedEvent(ctx, assistantReply));
log.debug("[Memory] afterLlmCall: agent={}, conv={}, replyLen={}", ctx.agentId(),
ctx.conversationId(), assistantReply != null ? assistantReply.length() : 0);
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
index bbbcf0b7..c5c8e302 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryManager.java
@@ -189,9 +189,19 @@ public class MemoryManager {
*/
public void syncAll(Long agentId, String conversationId,
String userMessage, String assistantReply) {
+ syncAll(agentId, conversationId, userMessage, assistantReply, null);
+ }
+
+ /**
+ * Owner-scoped post-turn sync. Passes the same resolved memory
+ * {@code ownerKey} that prefetch used, so owner-aware providers persist
+ * the turn under the identifier their recall path queries by.
+ */
+ public void syncAll(Long agentId, String conversationId,
+ String userMessage, String assistantReply, String ownerKey) {
for (MemoryProvider provider : providers) {
try {
- provider.syncTurn(agentId, conversationId, userMessage, assistantReply);
+ provider.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey);
} catch (Exception e) {
log.warn("[MemoryManager] Provider '{}' syncTurn failed: {}",
provider.id(), e.getMessage());
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
index 00595074..262c0c32 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java
@@ -87,6 +87,24 @@ public interface MemoryProvider {
String userMessage, String assistantReply) {
}
+ /**
+ * Owner-scoped post-turn sync. Providers that isolate memory per end-user
+ * override this to persist the turn under the same {@code ownerKey} that
+ * owner-scoped prefetch recalls by. Default delegates to
+ * {@link #syncTurn(Long, String, String, String)} for providers that are
+ * not owner-aware.
+ *
+ * @param agentId the agent ID
+ * @param conversationId the conversation ID
+ * @param userMessage user's message text
+ * @param assistantReply assistant's reply text
+ * @param ownerKey resolved memory owner key (e.g. "user:42"); may be null
+ */
+ default void syncTurn(Long agentId, String conversationId,
+ String userMessage, String assistantReply, String ownerKey) {
+ syncTurn(agentId, conversationId, userMessage, assistantReply);
+ }
+
/**
* Spring AI @Tool beans this provider wants to expose to the agent.
* These are collected by MemoryManager and added to the tool set.
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java
index 1617c0be..d7480fa0 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/decorator/MemoryProviderDecorator.java
@@ -28,6 +28,9 @@ public abstract class MemoryProviderDecorator implements MemoryProvider {
@Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) {
delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
}
+ @Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply, String ownerKey) {
+ delegate.syncTurn(agentId, conversationId, userMessage, assistantReply, ownerKey);
+ }
@Override public List