feat(memory): per-owner memory isolation with owner_key + visibility scope (#235)

This commit is contained in:
matevip 2026-06-02 17:04:06 +08:00
parent 40ce1c67ac
commit 48611a6f4d
38 changed files with 1179 additions and 163 deletions

View File

@ -49,6 +49,7 @@ public class AgentService {
private final MemoryRecallTracker memoryRecallTracker;
private final MemoryLifecycleMediator lifecycleMediator;
private final MemoryProperties memoryProperties;
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
/** Read-only lookup of a conversation's pinned model. Mapper (not service)
* to keep this a leaf dependency with no risk of a bean cycle. */
private final ConversationMapper conversationMapper;
@ -518,7 +519,8 @@ public class AgentService {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId);
}
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
// Inject memory context into the user message (RFC-037 §3.3)
String enrichedMessage = injectMemoryContext(message, memoryContext);
@ -540,7 +542,8 @@ public class AgentService {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId);
}
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
String enrichedMessage = injectMemoryContext(message, memoryContext);
StringBuilder reply = new StringBuilder();

View File

@ -836,7 +836,7 @@ public class ChannelMessageRouter {
usage[0], usage[1], modelInfo[0], modelInfo[1]);
savedAssistantId = saved != null ? saved.getId() : null;
if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin);
}
adapter.renderAndSend(replyTarget, reply);
log.info("[{}] Reply sent to {}: {}chars",
@ -984,7 +984,7 @@ public class ChannelMessageRouter {
conversationId, "assistant", finalContent, null, status,
usage[0], usage[1], modelInfo[0], modelInfo[1]);
if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent);
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin);
}
log.info("[{}] Streaming completed: contentLen={}, isError={}",
channelType, finalContent.length(), isError);
@ -1160,8 +1160,13 @@ public class ChannelMessageRouter {
* messageCount lookup no longer live here.
*/
private void publishConversationCompletedEvent(Long agentId, String conversationId,
String userMessage, String assistantReply) {
completionPublisher.publish(agentId, conversationId, userMessage, assistantReply, "channel");
String userMessage, String assistantReply,
ChatOrigin origin) {
// Attribute the memory write to the same external sender the read path
// recalled for, so per-sender IM memory is both written and recalled
// under the same owner key.
completionPublisher.publishForOrigin(agentId, conversationId, userMessage, assistantReply,
"channel", origin);
}
// ==================== 流式处理Web 渠道专用不走队列 ====================

View File

@ -61,6 +61,7 @@ public class ChatController {
private final ChatStreamTracker streamTracker;
private final ObjectMapper objectMapper;
private final ConversationCompletionPublisher completionPublisher;
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
private final Path uploadRoot = Paths.get("data", "chat-uploads");
// 使用虚拟线程池处理 SSEJava 17+ 兼容Java 21 可用 Executors.newVirtualThreadPerTaskExecutor()
@ -543,7 +544,7 @@ public class ChatController {
// tools that need a workspace path read it from the agent (origin
// is enriched with workspaceBasePath in StateGraph buildInitialState).
vip.mate.agent.context.ChatOrigin webOrigin =
vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null);
memoryOrigin(conversationId, username, workspaceId, request.getEndUserId());
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
.doOnNext(delta -> {
if (emitterDone.get()) return;
@ -630,7 +631,12 @@ public class ChatController {
// garbage like "[错误] Bad request..." as the assistant reply,
// which would pollute the memory extraction pipeline if propagated.
if (!wasStopped && !isError) {
completionPublisher.publish(agentId, conversationId, message, assistantText, "web");
// Attribute the memory write to the same owner the read
// path recalled this turn the publish runs in a reactive
// completion callback after the origin holder is cleared,
// so resolve from the captured webOrigin explicitly.
completionPublisher.publish(agentId, conversationId, message, assistantText, "web",
memoryOwnerResolver.resolve(webOrigin));
}
if (isInterruptFollowup) {
@ -1035,12 +1041,17 @@ public class ChatController {
conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), request.getContentParts());
String promptText = buildPromptText(request.getMessage(), request.getContentParts());
AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId());
// Carry the web origin so per-owner memory recall (read) and the
// post-conversation memory write below agree on the same owner key.
vip.mate.agent.context.ChatOrigin webOrigin =
memoryOrigin(request.getConversationId(), username, workspaceId, request.getEndUserId());
AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin);
String response = result.content();
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
result.promptTokens(), result.completionTokens(),
result.runtimeModel(), result.runtimeProvider());
completionPublisher.publish(agentId, request.getConversationId(), request.getMessage(), response, "web");
completionPublisher.publish(agentId, request.getConversationId(), request.getMessage(), response, "web",
memoryOwnerResolver.resolve(webOrigin));
return R.ok(response);
}
@ -1123,11 +1134,36 @@ public class ChatController {
.body(resource);
}
/**
* Build the {@link vip.mate.agent.context.ChatOrigin} that drives per-owner
* memory isolation for a web request. When {@code endUserId} is supplied
* (third-party single-account integration) the origin is attributed to that
* external end-user ({@code api:<endUserId>}); otherwise to the logged-in
* MateClaw user ({@code user:<username>}).
*/
private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username,
Long workspaceId, String endUserId) {
if (endUserId != null && !endUserId.isBlank()) {
return vip.mate.agent.context.ChatOrigin
.web(conversationId, endUserId.trim(), workspaceId, null)
.withSender(null, "api", null);
}
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null);
}
@lombok.Data
public static class ChatRequest {
private String message;
private String conversationId = "default";
private List<MessageContentPart> contentParts;
/**
* Optional third-party end-user identifier. When a single MateClaw
* account (e.g. one PAT) fronts many of an external system's users,
* pass that system's user id here so memory and recall are isolated
* per end-user. Kept as a string (never coerced to a number) to
* preserve precision of large external ids.
*/
private String endUserId;
}
@lombok.Data
@ -1167,6 +1203,12 @@ public class ChatController {
private String modelProvider;
/** Model id the user picked for this conversation. See {@link #modelProvider}. */
private String modelName;
/**
* Optional third-party end-user identifier see
* {@link ChatRequest#getEndUserId()}. Isolates memory per external
* end-user when one MateClaw account fronts many of them.
*/
private String endUserId;
}
/**

View File

@ -144,9 +144,13 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
talkSession.conversationId, talkSession.agentId, talkSession.username, talkWsId);
conversationService.saveMessage(talkSession.conversationId, "user", transcript, List.of());
// 5. Agent 对话同步
// 5. Agent 对话同步Carry the voice user's identity so per-owner
// memory recall (read) and the post-turn memory write (below) agree
// on the same owner key.
vip.mate.agent.context.ChatOrigin talkOrigin = vip.mate.agent.context.ChatOrigin.web(
talkSession.conversationId, talkSession.username, talkWsId, null);
AgentService.ChatResult chatResult = agentService.chatWithUsage(
talkSession.agentId, transcript, talkSession.conversationId);
talkSession.agentId, transcript, talkSession.conversationId, talkOrigin);
String reply = chatResult.content();
if (reply == null || reply.isBlank()) {
reply = "Sorry, I couldn't generate a response.";
@ -158,8 +162,8 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
chatResult.runtimeModel(), chatResult.runtimeProvider());
// Publish conversation-completed event so memory extraction runs for voice turns too.
completionPublisher.publish(talkSession.agentId, talkSession.conversationId,
transcript, reply, "talk");
completionPublisher.publishForOrigin(talkSession.agentId, talkSession.conversationId,
transcript, reply, "talk", talkOrigin);
// 7. 推送文字回复
sendJson(session, Map.of("type", "reply", "text", reply));

View File

@ -50,6 +50,7 @@ public class WebChatController {
private final ChatStreamTracker streamTracker;
private final ObjectMapper objectMapper;
private final ConversationCompletionPublisher completionPublisher;
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
@ -122,7 +123,16 @@ public class WebChatController {
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
agentService.chatStructuredStream(agentId, message, conversationId, visitorId)
// Attribute memory to this external visitor so each end-user
// behind the shared webchat account is isolated. The same origin
// resolves the owner key for both the read (recall) and write
// (publish) paths below.
vip.mate.agent.context.ChatOrigin webchatOrigin =
vip.mate.agent.context.ChatOrigin.web(conversationId, visitorId, webWsId, null)
.withSender(null, "api", null);
String webchatOwnerKey = memoryOwnerResolver.resolve(webchatOrigin);
agentService.chatStructuredStream(agentId, message, conversationId, visitorId, null, webchatOrigin)
.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
@ -155,7 +165,7 @@ public class WebChatController {
"completed", usage[0], usage[1], modelInfo[0], modelInfo[1]);
}
completionPublisher.publish(
agentId, conversationId, message, reply, "webchat");
agentId, conversationId, message, reply, "webchat", webchatOwnerKey);
} catch (Exception persistErr) {
log.warn("[WebChat] Failed to persist assistant reply / publish event: {}",
persistErr.getMessage());

View File

@ -11,6 +11,9 @@ package vip.mate.memory.event;
* @param assistantReply Agent 最终回答
* @param messageCount 当前会话消息总数
* @param triggerSource 触发来源"web" / "channel" / "cron"
* @param ownerKey memory owner this turn is attributed to (e.g.
* "user:42"); null / "system" means not owner-scoped,
* in which case extracted memory is written as shared.
* @author MateClaw Team
*/
public record ConversationCompletedEvent(
@ -19,5 +22,12 @@ public record ConversationCompletedEvent(
String userMessage,
String assistantReply,
int messageCount,
String triggerSource
) {}
String triggerSource,
String ownerKey
) {
/** Backwards-compatible constructor without an owner key (resolves to null). */
public ConversationCompletedEvent(Long agentId, String conversationId, String userMessage,
String assistantReply, int messageCount, String triggerSource) {
this(agentId, conversationId, userMessage, assistantReply, messageCount, triggerSource, null);
}
}

View File

@ -4,6 +4,9 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.memory.identity.MemoryOwnerResolver;
import vip.mate.workspace.conversation.ConversationService;
/**
@ -32,6 +35,7 @@ public class ConversationCompletionPublisher {
private final ApplicationEventPublisher eventPublisher;
private final ConversationService conversationService;
private final MemoryOwnerResolver memoryOwnerResolver;
/**
* Publish a {@link ConversationCompletedEvent} for the given turn.
@ -51,6 +55,43 @@ public class ConversationCompletionPublisher {
String userMessage,
String assistantReply,
String source) {
// Best-effort owner resolution from the request-scoped origin holder.
// Reliable for callers that publish on the same thread the origin was
// captured on (IM router, talk mode, cron). Web entry points publish
// from a reactive completion callback after the holder is cleared, so
// they MUST use the explicit overload below to stay consistent with the
// read path's owner key.
publish(agentId, conversationId, userMessage, assistantReply, source,
memoryOwnerResolver.resolve(ChatOriginHolder.get()));
}
/**
* Publish, attributing the memory write to the owner resolved from an
* explicit {@link ChatOrigin}. Use from entry points (IM channels, talk
* mode) that publish after the request-scoped origin holder is cleared, so
* the write owner matches the read path's owner for the same turn.
*/
public void publishForOrigin(Long agentId,
String conversationId,
String userMessage,
String assistantReply,
String source,
ChatOrigin origin) {
publish(agentId, conversationId, userMessage, assistantReply, source,
memoryOwnerResolver.resolve(origin));
}
/**
* Publish with an explicit {@code ownerKey}. Use this from entry points
* where the request-scoped origin is no longer on the current thread, so
* the memory write is attributed to the same owner the read path recalls.
*/
public void publish(Long agentId,
String conversationId,
String userMessage,
String assistantReply,
String source,
String ownerKey) {
if (agentId == null || conversationId == null || conversationId.isBlank()) {
return;
}
@ -62,7 +103,8 @@ public class ConversationCompletionPublisher {
userMessage != null ? userMessage : "",
assistantReply != null ? assistantReply : "",
messageCount,
source != null ? source : "unknown"));
source != null ? source : "unknown",
ownerKey));
} catch (Exception e) {
log.debug("[Memory] Failed to publish ConversationCompletedEvent (source={}, conv={}): {}",
source, conversationId, e.getMessage());

View File

@ -50,6 +50,12 @@ public class FactEntity {
/** pattern | llm */
private String extractedBy;
/** Memory subject this fact belongs to (e.g. "user:42"); null for shared/legacy rows. */
private String ownerKey;
/** Visibility scope: PERSONAL / TEAM / GLOBAL. Defaults to TEAM at the DB level. */
private String scope;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -47,8 +47,13 @@ public class FactMemoryProvider implements MemoryProvider {
@Override
public String prefetch(Long agentId, String userQuery) {
return prefetch(agentId, userQuery, null);
}
@Override
public String prefetch(Long agentId, String userQuery, String ownerKey) {
if (!properties.getFact().isProjectionEnabled()) return "";
List<FactEntity> facts = queryService.recallRelevant(agentId, userQuery);
List<FactEntity> facts = queryService.recallRelevant(agentId, userQuery, ownerKey);
if (facts.isEmpty()) return "";
// Bump usage

View File

@ -56,6 +56,16 @@ public class FactQueryService {
* Recall relevant facts for a query (used by FactMemoryProvider.prefetch).
*/
public List<FactEntity> recallRelevant(Long agentId, String query) {
return recallRelevant(agentId, query, null);
}
/**
* Owner-scoped recall: returns facts visible to {@code ownerKey} shared
* (TEAM / GLOBAL) facts plus this owner's PERSONAL facts. A null ownerKey
* means shared-only. Keeps one user's recalled facts out of another user's
* prompt when a single agent is shared across end-users.
*/
public List<FactEntity> recallRelevant(Long agentId, String query, String ownerKey) {
return factMapper.selectList(
new LambdaQueryWrapper<FactEntity>()
.eq(FactEntity::getAgentId, agentId)
@ -63,6 +73,18 @@ public class FactQueryService {
.and(w -> w.like(FactEntity::getSubject, query)
.or().like(FactEntity::getObjectValue, query)
.or().like(FactEntity::getPredicate, query))
.and(s -> {
if (ownerKey == null || ownerKey.isBlank()) {
s.in(FactEntity::getScope, vip.mate.memory.identity.MemoryScope.TEAM,
vip.mate.memory.identity.MemoryScope.GLOBAL);
} else {
s.in(FactEntity::getScope, vip.mate.memory.identity.MemoryScope.TEAM,
vip.mate.memory.identity.MemoryScope.GLOBAL)
.or(p -> p.eq(FactEntity::getScope,
vip.mate.memory.identity.MemoryScope.PERSONAL)
.eq(FactEntity::getOwnerKey, ownerKey));
}
})
.orderByDesc(FactEntity::getTrust)
.last("LIMIT 10"));
}

View File

@ -0,0 +1,53 @@
package vip.mate.memory.identity;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
/**
* Resolves the {@code owner_key} that conversation-derived memory should be
* attributed to, from the request's {@link ChatOrigin}.
*
* <p>The key is a prefixed string so a single agent shared across surfaces
* keeps every subject's memory separate:
* <ul>
* <li>Web console {@code user:<requesterId>} (the MateClaw username)</li>
* <li>IM channels {@code <channelType>:<senderId>} (feishu/dingtalk/)</li>
* <li>WebChat / 3rd-party API {@code api:<visitorId|endUserId>}</li>
* <li>Cron / system / unknown {@link #SYSTEM_OWNER}</li>
* </ul>
*
* The cron/system fallback is deliberate: it keeps unattributed writes out of
* any real user's PERSONAL bucket (which would otherwise be a black hole that
* nobody can read) such writes are expected to be TEAM-scoped instead.
*
* @author MateClaw Team
*/
@Component
public class MemoryOwnerResolver {
/** Owner key used for cron-triggered and identity-less invocations. */
public static final String SYSTEM_OWNER = "system";
/**
* Resolve the owner key for the given origin. Never returns null; falls
* back to {@link #SYSTEM_OWNER} when no usable identity is present.
*/
public String resolve(ChatOrigin origin) {
if (origin == null || origin.cronOrigin()) {
return SYSTEM_OWNER;
}
String requester = origin.requesterId();
if (requester == null || requester.isBlank() || SYSTEM_OWNER.equals(requester)) {
return SYSTEM_OWNER;
}
String channel = origin.channelType();
if (channel == null || channel.isBlank() || "web".equals(channel)) {
// Web console (or a degraded origin with no channel): the requester
// id is already the MateClaw username.
return "user:" + requester;
}
// IM / api origins: the requester id is the external sender id; prefix
// with the channel type so two platforms can't collide on the same id.
return channel + ":" + requester;
}
}

View File

@ -0,0 +1,34 @@
package vip.mate.memory.identity;
/**
* Visibility scope for a memory row (workspace file, fact, recall).
*
* <ul>
* <li>{@link #PERSONAL} only the matching {@code owner_key} can read it.
* Conversation-derived memory defaults here.</li>
* <li>{@link #TEAM} everyone using the agent can read it. Agent config /
* persona files (AGENTS.md, SOUL.md, PROFILE.md) and legacy rows live
* here.</li>
* <li>{@link #GLOBAL} always visible. Reserved for agent-creator preset
* facts.</li>
* </ul>
*
* Stored as a plain string column ({@code scope}) rather than a DB enum so the
* H2 / MySQL migrations stay dialect-neutral.
*
* @author MateClaw Team
*/
public final class MemoryScope {
public static final String PERSONAL = "PERSONAL";
public static final String TEAM = "TEAM";
public static final String GLOBAL = "GLOBAL";
private MemoryScope() {
}
/** A scope is "shared" (visible to every owner) when it is TEAM or GLOBAL. */
public static boolean isShared(String scope) {
return TEAM.equals(scope) || GLOBAL.equals(scope);
}
}

View File

@ -42,7 +42,7 @@ public class MemoryLifecycleMediator {
*/
public String beforeLlmCall(TurnContext ctx) {
try {
String context = memoryManager.prefetchAll(ctx.agentId(), ctx.userQuery());
String context = memoryManager.prefetchAll(ctx.agentId(), ctx.userQuery(), ctx.ownerKey());
events.publishEvent(new TurnStartedEvent(ctx));
log.debug("[Memory] beforeLlmCall: agent={}, contextLen={}", ctx.agentId(),
context != null ? context.length() : 0);

View File

@ -8,6 +8,9 @@ package vip.mate.memory.lifecycle;
* @param sessionId session ID (may equal conversationId in Phase 1)
* @param turnNumber turn sequence number within the conversation
* @param userQuery the current user message
* @param ownerKey resolved memory owner key for this turn (e.g.
* "user:42"); drives per-owner memory recall. May be
* null when memory-isolation context is unavailable.
* @author MateClaw Team
*/
public record TurnContext(
@ -15,5 +18,12 @@ public record TurnContext(
String conversationId,
String sessionId,
int turnNumber,
String userQuery
) {}
String userQuery,
String ownerKey
) {
/** Backwards-compatible constructor without an owner key (resolves to null). */
public TurnContext(Long agentId, String conversationId, String sessionId,
int turnNumber, String userQuery) {
this(agentId, conversationId, sessionId, turnNumber, userQuery, null);
}
}

View File

@ -52,7 +52,7 @@ public class PostConversationMemoryListener {
try {
log.debug("[Memory] Triggering post-conversation memory analysis: agent={}, conv={}",
event.agentId(), event.conversationId());
summarizationService.analyzeAndUpdateMemory(event.agentId(), event.conversationId());
summarizationService.analyzeAndUpdateMemory(event.agentId(), event.conversationId(), event.ownerKey());
} catch (Exception e) {
log.warn("[Memory] Post-conversation summarization failed: agent={}, conv={}, error={}",
event.agentId(), event.conversationId(), e.getMessage());
@ -60,7 +60,7 @@ public class PostConversationMemoryListener {
// Memory Nudge: extract structured entries every N turns
try {
nudgeService.maybeNudge(event.agentId(), event.conversationId(), event.messageCount());
nudgeService.maybeNudge(event.agentId(), event.conversationId(), event.messageCount(), event.ownerKey());
} catch (Exception e) {
log.debug("[Memory] Nudge trigger failed (non-fatal): {}", e.getMessage());
}

View File

@ -56,6 +56,12 @@ public class MemoryRecallEntity {
/** Last time this candidate was reviewed during a dream run */
private LocalDateTime lastReviewedAt;
/** Memory subject this recall belongs to (e.g. "user:42"); null for shared/legacy rows. */
private String ownerKey;
/** Visibility scope: PERSONAL / TEAM / GLOBAL. Defaults to TEAM at the DB level. */
private String scope;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -46,17 +46,29 @@ public class MemoryNudgeService {
private final ObjectMapper objectMapper;
/** Per-agent cooldown tracking */
private final ConcurrentHashMap<Long, Instant> lastNudgeTimes = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Instant> lastNudgeTimes = new ConcurrentHashMap<>();
/**
* Check if a nudge should be triggered and execute if so.
* Called from PostConversationMemoryListener or directly.
*/
/** Backwards-compatible entry without an owner key (writes shared memory). */
@Async
public void maybeNudge(Long agentId, String conversationId, int messageCount) {
maybeNudge(agentId, conversationId, messageCount, null);
}
@Async
public void maybeNudge(Long agentId, String conversationId, int messageCount, String ownerKey) {
if (!properties.isNudgeEnabled()) {
return;
}
// Gate per-owner isolation on the lifecycle prefetch path (the only
// auto-injector of PERSONAL structured memory); otherwise write shared
// so nudged entries are not stranded in an unread PERSONAL bucket.
if (!properties.isLifecycleMediatorEnabled()) {
ownerKey = null;
}
// Check turn interval
if (properties.getNudgeTurnInterval() <= 0
@ -64,22 +76,23 @@ public class MemoryNudgeService {
return;
}
// Cooldown check
if (isInCooldown(agentId)) {
log.debug("[Nudge] Agent {} is in cooldown, skipping", agentId);
// Cooldown keyed per (agent, owner) so one owner can't starve another.
String cooldownKey = agentId + ":" + (ownerKey == null ? "" : ownerKey);
if (isInCooldown(cooldownKey)) {
log.debug("[Nudge] Agent {} (owner {}) is in cooldown, skipping", agentId, ownerKey);
return;
}
try {
doNudge(agentId, conversationId);
lastNudgeTimes.put(agentId, Instant.now());
doNudge(agentId, conversationId, ownerKey);
lastNudgeTimes.put(cooldownKey, Instant.now());
} catch (Exception e) {
log.warn("[Nudge] Failed for agent={}, conv={}: {}",
agentId, conversationId, e.getMessage());
}
}
private void doNudge(Long agentId, String conversationId) {
private void doNudge(Long agentId, String conversationId, String ownerKey) {
// 1. Load recent messages
List<MessageEntity> messages = conversationService.listMessages(conversationId);
int maxReview = properties.getNudgeMaxMessages();
@ -96,8 +109,8 @@ public class MemoryNudgeService {
String transcript = buildTranscript(recent);
if (transcript.isBlank()) return;
// 3. Load existing structured memories for dedup
String existingMemories = structuredMemoryService.buildMemoryBlock(agentId);
// 3. Load existing structured memories for dedup (owner-scoped)
String existingMemories = structuredMemoryService.buildMemoryBlock(agentId, ownerKey);
// 4. Build prompt
String systemPrompt = PromptLoader.loadPrompt("memory/nudge-system");
@ -140,7 +153,7 @@ public class MemoryNudgeService {
if (type.isBlank() || key.isBlank() || content.isBlank()) continue;
try {
structuredMemoryService.remember(agentId, type, key, content, "nudge");
structuredMemoryService.remember(agentId, type, key, content, "nudge", ownerKey);
saved++;
} catch (Exception e) {
log.debug("[Nudge] Failed to save entry {}/{}: {}", type, key, e.getMessage());
@ -226,8 +239,8 @@ public class MemoryNudgeService {
|| msg.contains("速率限制") || msg.contains("Too Many Requests"));
}
private boolean isInCooldown(Long agentId) {
Instant lastRun = lastNudgeTimes.get(agentId);
private boolean isInCooldown(String cooldownKey) {
Instant lastRun = lastNudgeTimes.get(cooldownKey);
if (lastRun == null) return false;
long cooldownSeconds = properties.getNudgeCooldownMinutes() * 60L;
return Instant.now().isBefore(lastRun.plusSeconds(cooldownSeconds));

View File

@ -59,12 +59,24 @@ public class BuiltinMemoryProvider implements MemoryProvider {
}
/**
* Builtin memory is already injected via system prompt.
* No additional per-turn prefetch needed.
* Shared (TEAM / GLOBAL) memory is baked into the system prompt at build
* time. Per-owner PERSONAL memory cannot be the agent instance is cached
* and reused across users so it is injected here, per turn, for the
* current requester only.
*/
@Override
public String prefetch(Long agentId, String userQuery) {
return "";
public String prefetch(Long agentId, String userQuery, String ownerKey) {
if (ownerKey == null || ownerKey.isBlank()) {
return "";
}
try {
String block = workspaceFileService.buildOwnerMemoryBlock(agentId, ownerKey);
return block != null ? block : "";
} catch (Exception e) {
log.warn("[BuiltinMemory] Failed to build owner memory block for agent={}, owner={}: {}",
agentId, ownerKey, e.getMessage());
return "";
}
}
/**

View File

@ -38,31 +38,61 @@ public class StructuredMemoryProvider implements MemoryProvider {
* Returns the stable, low-volume typed entries (user profile, feedback)
* for unconditional system prompt injection.
*/
/**
* Build-time injection is limited to SHARED (TEAM / GLOBAL) structured
* memory agent-creator presets and team-wide facts. Conversation-derived
* PERSONAL structured memory is owner-specific and the agent instance is
* cached across users, so it is injected per-turn in
* {@link #prefetch(Long, String, String)} for the current owner only.
*/
@Override
public String systemPromptBlock(Long agentId) {
try {
return structuredMemoryService.buildMemoryBlock(agentId);
// ownerKey=null buildMemoryBlock reads shared (TEAM/GLOBAL) rows only.
return structuredMemoryService.buildMemoryBlock(agentId, null);
} catch (Exception e) {
log.warn("[StructuredMemory] Failed to build memory block for agent={}: {}",
log.warn("[StructuredMemory] Failed to build shared memory block for agent={}: {}",
agentId, e.getMessage());
return "";
}
}
/**
* Returns growing/specific typed entries (project facts, reference notes)
* relevant to the current question. Surfacing these per-turn rather than
* always-on keeps them salient when asked about and avoids the model
* confusing a stored fact with similarly-shaped background knowledge.
* The returned block is fenced centrally by the memory manager.
*/
@Override
public String prefetch(Long agentId, String userQuery) {
return prefetch(agentId, userQuery, null);
}
/**
* Owner-scoped per-turn injection: the stable user/feedback entries plus the
* query-relevant project/reference entries all restricted to the current
* owner's structured memory. Returns empty when there is no isolatable owner
* so a shared agent never injects another user's structured memory. The
* returned block is fenced centrally by the memory manager.
*/
@Override
public String prefetch(Long agentId, String userQuery, String ownerKey) {
try {
return structuredMemoryService.buildPrefetchBlock(agentId, userQuery);
String stable = structuredMemoryService.buildMemoryBlock(agentId, ownerKey);
String relevant = structuredMemoryService.buildPrefetchBlock(agentId, userQuery, ownerKey);
boolean hasStable = stable != null && !stable.isBlank();
boolean hasRelevant = relevant != null && !relevant.isBlank();
if (!hasStable && !hasRelevant) {
return "";
}
StringBuilder sb = new StringBuilder();
if (hasStable) {
sb.append(stable);
}
if (hasRelevant) {
if (sb.length() > 0) {
sb.append("\n\n");
}
sb.append(relevant);
}
return sb.toString();
} catch (Exception e) {
log.warn("[StructuredMemory] Failed to build prefetch block for agent={}: {}",
agentId, e.getMessage());
log.warn("[StructuredMemory] Failed to build prefetch block for agent={}, owner={}: {}",
agentId, ownerKey, e.getMessage());
return "";
}
}

View File

@ -52,40 +52,60 @@ public class MemorySummarizationService {
private static final java.util.Set<String> STRUCTURED_TYPES =
java.util.Set.of("user", "feedback", "project", "reference");
/** Per-agent 锁,防止并发写入 */
private final ConcurrentHashMap<Long, ReentrantLock> agentLocks = new ConcurrentHashMap<>();
/** Per-(agent, owner) 锁,防止并发写入 */
private final ConcurrentHashMap<String, ReentrantLock> agentLocks = new ConcurrentHashMap<>();
/** Per-agent 冷却时间记录 */
private final ConcurrentHashMap<Long, Instant> lastRunTimes = new ConcurrentHashMap<>();
/** Per-(agent, owner) 冷却时间记录 */
private final ConcurrentHashMap<String, Instant> lastRunTimes = new ConcurrentHashMap<>();
/** Backwards-compatible entry without an owner key (writes shared memory). */
public void analyzeAndUpdateMemory(Long agentId, String conversationId) {
analyzeAndUpdateMemory(agentId, conversationId, null);
}
/**
* 分析对话并更新记忆文件
*
* @param agentId Agent ID
* @param conversationId 会话 ID
* @param ownerKey memory owner this conversation is attributed to; null
* or "system" writes shared (TEAM) memory, otherwise
* memory is written PERSONAL to this owner
*/
public void analyzeAndUpdateMemory(Long agentId, String conversationId) {
public void analyzeAndUpdateMemory(Long agentId, String conversationId, String ownerKey) {
// Per-owner isolation is gated on the lifecycle prefetch path, which is
// the only auto-injector of PERSONAL memory. When that path is off,
// writing PERSONAL would strand memory in a bucket nothing auto-reads,
// so fall back to shared (legacy) writes isolation activates together
// with lifecycleMediatorEnabled.
if (!properties.isLifecycleMediatorEnabled()) {
ownerKey = null;
}
// Lock / cooldown are keyed per (agent, owner) so one owner's busy
// extraction never starves another owner sharing the same agent.
String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey);
// 冷却检查
if (isInCooldown(agentId)) {
log.debug("[Memory] Agent {} is in cooldown, skipping summarization", agentId);
if (isInCooldown(lockKey)) {
log.debug("[Memory] Agent {} (owner {}) is in cooldown, skipping summarization", agentId, ownerKey);
return;
}
ReentrantLock lock = agentLocks.computeIfAbsent(agentId, k -> new ReentrantLock());
ReentrantLock lock = agentLocks.computeIfAbsent(lockKey, k -> new ReentrantLock());
if (!lock.tryLock()) {
log.debug("[Memory] Agent {} is already being summarized, skipping", agentId);
log.debug("[Memory] Agent {} (owner {}) is already being summarized, skipping", agentId, ownerKey);
return;
}
try {
doAnalyzeAndUpdate(agentId, conversationId);
lastRunTimes.put(agentId, Instant.now());
doAnalyzeAndUpdate(agentId, conversationId, ownerKey);
lastRunTimes.put(lockKey, Instant.now());
} finally {
lock.unlock();
}
}
private void doAnalyzeAndUpdate(Long agentId, String conversationId) {
private void doAnalyzeAndUpdate(Long agentId, String conversationId, String ownerKey) {
// 1. 加载对话消息
List<MessageEntity> messages = conversationService.listMessages(conversationId);
if (messages.size() < properties.getMinMessagesForSummarize()) {
@ -100,11 +120,11 @@ public class MemorySummarizationService {
return;
}
// 2. 加载现有记忆文件内容
String profileContent = readFileContentSafe(agentId, "PROFILE.md");
String memoryContent = readFileContentSafe(agentId, "MEMORY.md");
// 2. 加载现有记忆文件内容 owner 隔离
String profileContent = readFileContentSafe(agentId, "PROFILE.md", ownerKey);
String memoryContent = readFileContentSafe(agentId, "MEMORY.md", ownerKey);
String dailyFilename = "memory/" + LocalDate.now() + ".md";
String dailyContent = readFileContentSafe(agentId, dailyFilename);
String dailyContent = readFileContentSafe(agentId, dailyFilename, ownerKey);
// 3. 构建对话 transcript
String transcript = buildTranscript(messages);
@ -153,7 +173,7 @@ public class MemorySummarizationService {
}
// 6. 应用更新
applyUpdates(agentId, root, dailyFilename, dailyContent);
applyUpdates(agentId, root, dailyFilename, dailyContent, ownerKey);
String reason = root.path("reason").asText("");
log.info("[Memory] Memory updated for agent={}, conv={}: {}", agentId, conversationId, reason);
@ -164,7 +184,8 @@ public class MemorySummarizationService {
}
}
private void applyUpdates(Long agentId, JsonNode root, String dailyFilename, String existingDailyContent) {
private void applyUpdates(Long agentId, JsonNode root, String dailyFilename,
String existingDailyContent, String ownerKey) {
// Daily entry: 追加模式
JsonNode dailyNode = root.path("daily_entry");
if (!dailyNode.isNull() && dailyNode.isTextual()) {
@ -173,8 +194,8 @@ public class MemorySummarizationService {
String newContent = existingDailyContent.isEmpty()
? "# " + LocalDate.now() + "\n\n" + entry
: existingDailyContent + "\n\n" + entry;
workspaceFileService.saveFile(agentId, dailyFilename, newContent);
log.info("[Memory] Appended daily entry to {} for agent={}", dailyFilename, agentId);
saveMemory(agentId, dailyFilename, newContent, ownerKey);
log.info("[Memory] Appended daily entry to {} for agent={}, owner={}", dailyFilename, agentId, ownerKey);
}
}
@ -183,8 +204,8 @@ public class MemorySummarizationService {
if (!memoryNode.isNull() && memoryNode.isTextual()) {
String content = memoryNode.asText().trim();
if (!content.isEmpty()) {
workspaceFileService.saveFile(agentId, "MEMORY.md", content);
log.info("[Memory] Updated MEMORY.md for agent={}", agentId);
saveMemory(agentId, "MEMORY.md", content, ownerKey);
log.info("[Memory] Updated MEMORY.md for agent={}, owner={}", agentId, ownerKey);
}
}
@ -193,8 +214,8 @@ public class MemorySummarizationService {
if (!profileNode.isNull() && profileNode.isTextual()) {
String content = profileNode.asText().trim();
if (!content.isEmpty()) {
workspaceFileService.saveFile(agentId, "PROFILE.md", content);
log.info("[Memory] Updated PROFILE.md for agent={}", agentId);
saveMemory(agentId, "PROFILE.md", content, ownerKey);
log.info("[Memory] Updated PROFILE.md for agent={}, owner={}", agentId, ownerKey);
}
}
@ -202,10 +223,10 @@ public class MemorySummarizationService {
// reference facts kept out of the always-on MEMORY.md) into structured
// memory so they become query-conditioned recallable, instead of being
// stranded in daily notes that only the agent's tools can reach.
applyStructuredEntries(agentId, root.path("structured_entries"));
applyStructuredEntries(agentId, root.path("structured_entries"), ownerKey);
}
private void applyStructuredEntries(Long agentId, JsonNode entriesNode) {
private void applyStructuredEntries(Long agentId, JsonNode entriesNode, String ownerKey) {
if (entriesNode == null || !entriesNode.isArray() || entriesNode.isEmpty()) {
return;
}
@ -220,7 +241,7 @@ public class MemorySummarizationService {
continue;
}
try {
structuredMemoryService.remember(agentId, type, key, content, "auto-summary");
structuredMemoryService.remember(agentId, type, key, content, "auto-summary", ownerKey);
written++;
} catch (Exception e) {
log.warn("[Memory] Failed to write structured entry '{}' (type={}) for agent={}: {}",
@ -286,15 +307,40 @@ public class MemorySummarizationService {
}
}
private String readFileContentSafe(Long agentId, String filename) {
/**
* Read an owner-scoped memory file. When {@code ownerKey} denotes a real
* owner the row is looked up by (agent, filename, owner); otherwise it falls
* back to the shared file so cron / system extraction keeps working.
*/
private String readFileContentSafe(Long agentId, String filename, String ownerKey) {
try {
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
WorkspaceFileEntity file = isPersonal(ownerKey)
? workspaceFileService.getMemoryFile(agentId, filename, ownerKey)
: workspaceFileService.getFile(agentId, filename);
return file != null && file.getContent() != null ? file.getContent() : "";
} catch (Exception e) {
return "";
}
}
/**
* Persist extracted memory to the owner's PERSONAL bucket, or to the shared
* (TEAM) file when there is no real owner (cron / system).
*/
private void saveMemory(Long agentId, String filename, String content, String ownerKey) {
if (isPersonal(ownerKey)) {
workspaceFileService.saveMemoryFile(agentId, filename, content, ownerKey);
} else {
workspaceFileService.saveFile(agentId, filename, content);
}
}
/** A real, isolatable owner — i.e. not null/blank and not the system bucket. */
private boolean isPersonal(String ownerKey) {
return ownerKey != null && !ownerKey.isBlank()
&& !vip.mate.memory.identity.MemoryOwnerResolver.SYSTEM_OWNER.equals(ownerKey);
}
/**
* 带轻量重试的 LLM 调用遇到 429 时等待后重试避免后台任务因限流直接放弃
* Spring AI RetryTemplate 已处理第一层重试此方法作为二次保护
@ -331,8 +377,8 @@ public class MemorySummarizationService {
|| msg.contains("速率限制") || msg.contains("Too Many Requests"));
}
private boolean isInCooldown(Long agentId) {
Instant lastRun = lastRunTimes.get(agentId);
private boolean isInCooldown(String lockKey) {
Instant lastRun = lastRunTimes.get(lockKey);
if (lastRun == null) return false;
long cooldownSeconds = properties.getCooldownMinutes() * 60L;
return Instant.now().isBefore(lastRun.plusSeconds(cooldownSeconds));

View File

@ -114,13 +114,18 @@ public class StructuredMemoryService {
* Uses per-file locking to handle concurrent tool calls writing to the same file.
*/
public void remember(Long agentId, String type, String key, String content, String source) {
remember(agentId, type, key, content, source, null);
}
/** Owner-scoped variant of {@link #remember}. */
public void remember(Long agentId, String type, String key, String content, String source, String ownerKey) {
validateType(type);
String filename = toFilename(type);
String lockKey = agentId + ":" + filename;
String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey) + ":" + filename;
ReentrantLock lock = fileLocks.computeIfAbsent(lockKey, k -> new ReentrantLock());
lock.lock();
try {
String fileContent = readFileSafe(agentId, filename);
String fileContent = readFileSafe(agentId, filename, ownerKey);
String metadata = "> Source: " + (source != null ? source : "agent")
+ " | Updated: " + LocalDate.now();
@ -136,7 +141,7 @@ public class StructuredMemoryService {
updated = fileContent.isBlank() ? newSection : fileContent.trim() + "\n\n" + newSection;
}
workspaceFileService.saveFile(agentId, filename, updated);
saveStructured(agentId, filename, updated, ownerKey);
log.info("[StructuredMemory] {} entry '{}' for agent={} (source={})",
existingSection != null ? "Updated" : "Added", key, agentId, source);
// Publish event for SOUL auto-evolution (Phase 2)
@ -150,6 +155,11 @@ public class StructuredMemoryService {
* Search entries by type and optional keyword.
*/
public List<Map<String, String>> recall(Long agentId, String type, String keyword) {
return recall(agentId, type, keyword, null);
}
/** Owner-scoped variant of {@link #recall(Long, String, String)}. */
public List<Map<String, String>> recall(Long agentId, String type, String keyword, String ownerKey) {
if (type != null) {
validateType(type);
}
@ -158,7 +168,7 @@ public class StructuredMemoryService {
List<Map<String, String>> results = new ArrayList<>();
for (String t : types) {
String fileContent = readFileSafe(agentId, toFilename(t));
String fileContent = readFileSafe(agentId, toFilename(t), ownerKey);
if (fileContent.isBlank()) continue;
Map<String, String> sections = parseSections(fileContent);
@ -181,13 +191,18 @@ public class StructuredMemoryService {
* Remove a memory entry by type and key.
*/
public boolean forget(Long agentId, String type, String key) {
return forget(agentId, type, key, null);
}
/** Owner-scoped variant of {@link #forget(Long, String, String)}. */
public boolean forget(Long agentId, String type, String key, String ownerKey) {
validateType(type);
String filename = toFilename(type);
String lockKey = agentId + ":" + filename;
String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey) + ":" + filename;
ReentrantLock lock = fileLocks.computeIfAbsent(lockKey, k -> new ReentrantLock());
lock.lock();
try {
String fileContent = readFileSafe(agentId, filename);
String fileContent = readFileSafe(agentId, filename, ownerKey);
if (fileContent.isBlank()) return false;
String section = findSection(fileContent, key);
@ -196,7 +211,7 @@ public class StructuredMemoryService {
String updated = fileContent.replace(section, "").trim();
// Clean up double blank lines
updated = updated.replaceAll("\n{3,}", "\n\n");
workspaceFileService.saveFile(agentId, filename, updated);
saveStructured(agentId, filename, updated, ownerKey);
log.info("[StructuredMemory] Removed entry '{}' (type={}) for agent={}", key, type, agentId);
return true;
} finally {
@ -211,17 +226,27 @@ public class StructuredMemoryService {
return recall(agentId, type, null);
}
/** Owner-scoped variant of {@link #listEntries(Long, String)}. */
public List<Map<String, String>> listEntries(Long agentId, String type, String ownerKey) {
return recall(agentId, type, null, ownerKey);
}
/**
* Build a formatted memory block for system prompt injection.
* Includes only the stable, low-volume entry types ({@link #SYSTEM_PROMPT_TYPES});
* growing/specific types are surfaced per-turn via {@link #buildPrefetchBlock}.
*/
public String buildMemoryBlock(Long agentId) {
return buildMemoryBlock(agentId, null);
}
/** Owner-scoped variant of {@link #buildMemoryBlock(Long)}. */
public String buildMemoryBlock(Long agentId, String ownerKey) {
StringBuilder sb = new StringBuilder();
boolean hasContent = false;
for (String type : SYSTEM_PROMPT_TYPES) {
String fileContent = readFileSafe(agentId, toFilename(type));
String fileContent = readFileSafe(agentId, toFilename(type), ownerKey);
if (fileContent.isBlank()) continue;
Map<String, String> sections = parseSections(fileContent);
@ -253,9 +278,14 @@ public class StructuredMemoryService {
* instead of the specific stored fact.
*/
public String buildPrefetchBlock(Long agentId, String userQuery) {
return buildPrefetchBlock(agentId, userQuery, null);
}
/** Owner-scoped variant of {@link #buildPrefetchBlock(Long, String)}. */
public String buildPrefetchBlock(Long agentId, String userQuery, String ownerKey) {
if (userQuery == null || userQuery.isBlank()) return "";
List<ScoredEntry> scored = recallRelevant(agentId, userQuery, PREFETCH_TYPES, MAX_PREFETCH_ENTRIES);
List<ScoredEntry> scored = recallRelevant(agentId, userQuery, PREFETCH_TYPES, MAX_PREFETCH_ENTRIES, ownerKey);
if (scored.isEmpty()) return "";
boolean hasProject = scored.stream().anyMatch(e -> "project".equals(e.type()));
@ -282,12 +312,16 @@ public class StructuredMemoryService {
* highest-scoring matches (score &gt; 0), best first, capped at {@code limit}.
*/
private List<ScoredEntry> recallRelevant(Long agentId, String userQuery, List<String> types, int limit) {
return recallRelevant(agentId, userQuery, types, limit, null);
}
private List<ScoredEntry> recallRelevant(Long agentId, String userQuery, List<String> types, int limit, String ownerKey) {
String q = userQuery.toLowerCase();
Set<String> queryShingles = shingles(q);
List<ScoredEntry> matches = new ArrayList<>();
for (String t : types) {
String fileContent = readFileSafe(agentId, toFilename(t));
String fileContent = readFileSafe(agentId, toFilename(t), ownerKey);
if (fileContent.isBlank()) continue;
for (Map.Entry<String, String> entry : parseSections(fileContent).entrySet()) {
@ -374,14 +408,35 @@ public class StructuredMemoryService {
}
private String readFileSafe(Long agentId, String filename) {
return readFileSafe(agentId, filename, null);
}
private String readFileSafe(Long agentId, String filename, String ownerKey) {
try {
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
WorkspaceFileEntity file = isPersonal(ownerKey)
? workspaceFileService.getMemoryFile(agentId, filename, ownerKey)
: workspaceFileService.getFile(agentId, filename);
return file != null && file.getContent() != null ? file.getContent() : "";
} catch (Exception e) {
return "";
}
}
/** Persist structured memory to the owner's PERSONAL bucket, or shared when no real owner. */
private void saveStructured(Long agentId, String filename, String content, String ownerKey) {
if (isPersonal(ownerKey)) {
workspaceFileService.saveMemoryFile(agentId, filename, content, ownerKey);
} else {
workspaceFileService.saveFile(agentId, filename, content);
}
}
/** A real, isolatable owner — not null/blank and not the system bucket. */
private boolean isPersonal(String ownerKey) {
return ownerKey != null && !ownerKey.isBlank()
&& !vip.mate.memory.identity.MemoryOwnerResolver.SYSTEM_OWNER.equals(ownerKey);
}
/**
* Parse all sections from a Markdown file.
* Returns map of key full section content (including metadata line).

View File

@ -104,10 +104,19 @@ public class MemoryManager {
* context as new user discourse.
*/
public String prefetchAll(Long agentId, String userQuery) {
return prefetchAll(agentId, userQuery, null);
}
/**
* Owner-scoped prefetch. Passes the resolved memory {@code ownerKey} so
* providers recall only the current requester's personal memory plus
* shared (TEAM / GLOBAL) memory (per-owner isolation).
*/
public String prefetchAll(Long agentId, String userQuery, String ownerKey) {
List<String> parts = new ArrayList<>();
for (MemoryProvider provider : providers) {
try {
String result = provider.prefetch(agentId, userQuery);
String result = provider.prefetch(agentId, userQuery, ownerKey);
if (result != null && !result.isBlank()) {
parts.add(sanitizeContext(result));
}

View File

@ -64,6 +64,21 @@ public interface MemoryProvider {
return "";
}
/**
* Owner-scoped pre-turn recall. Providers that isolate memory per end-user
* override this to recall only the given {@code ownerKey}'s personal memory
* plus shared memory. Default delegates to {@link #prefetch(Long, String)}
* for providers that are not owner-aware.
*
* @param agentId the agent ID
* @param userQuery the current user message
* @param ownerKey resolved memory owner key (e.g. "user:42"); may be null
* @return context text to inject, wrapped in a memory-context fence by MemoryManager
*/
default String prefetch(Long agentId, String userQuery, String ownerKey) {
return prefetch(agentId, userQuery);
}
/**
* Post-turn sync. Called after LLM response is available.
* Should be non-blocking (async).

View File

@ -24,6 +24,7 @@ public abstract class MemoryProviderDecorator implements MemoryProvider {
@Override public boolean isAvailable() { return delegate.isAvailable(); }
@Override public String systemPromptBlock(Long agentId) { return delegate.systemPromptBlock(agentId); }
@Override public String prefetch(Long agentId, String userQuery) { return delegate.prefetch(agentId, userQuery); }
@Override public String prefetch(Long agentId, String userQuery, String ownerKey) { return delegate.prefetch(agentId, userQuery, ownerKey); }
@Override public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) {
delegate.syncTurn(agentId, conversationId, userMessage, assistantReply);
}

View File

@ -40,9 +40,14 @@ public class MetricsMemoryProvider extends MemoryProviderDecorator {
@Override
public String prefetch(Long agentId, String userQuery) {
return prefetch(agentId, userQuery, null);
}
@Override
public String prefetch(Long agentId, String userQuery, String ownerKey) {
return prefetchTimer.record(() -> {
try {
return delegate.prefetch(agentId, userQuery);
return delegate.prefetch(agentId, userQuery, ownerKey);
} catch (Exception e) {
meterRegistry.counter("memory.prefetch.failures",
"provider", delegate.id()).increment();

View File

@ -20,10 +20,15 @@ public class RetryableMemoryProvider extends MemoryProviderDecorator {
@Override
public String prefetch(Long agentId, String userQuery) {
return prefetch(agentId, userQuery, null);
}
@Override
public String prefetch(Long agentId, String userQuery, String ownerKey) {
Exception lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return delegate.prefetch(agentId, userQuery);
return delegate.prefetch(agentId, userQuery, ownerKey);
} catch (Exception e) {
lastException = e;
if (attempt < maxAttempts) {

View File

@ -4,9 +4,13 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.memory.MemoryProperties;
import vip.mate.memory.identity.MemoryOwnerResolver;
import vip.mate.memory.service.StructuredMemoryService;
import java.util.List;
@ -28,6 +32,22 @@ import java.util.Map;
public class StructuredMemoryTool {
private final StructuredMemoryService structuredMemoryService;
private final MemoryOwnerResolver memoryOwnerResolver;
private final MemoryProperties memoryProperties;
/** Owner key for reads: the resolved requester (visibility = shared + own personal). */
private String readOwner(ToolContext ctx) {
return memoryOwnerResolver.resolve(ChatOrigin.from(ctx));
}
/**
* Owner key for writes/deletes: the resolved requester only when per-owner
* isolation is active; otherwise null so the entry lands in the shared
* bucket rather than an un-read PERSONAL row.
*/
private String writeOwner(ToolContext ctx) {
return memoryProperties.isLifecycleMediatorEnabled() ? readOwner(ctx) : null;
}
@Tool(description = """
记住一条结构化信息到 Agent 的长期记忆
@ -43,7 +63,8 @@ public class StructuredMemoryTool {
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "记忆类型user / feedback / project / reference") String type,
@ToolParam(description = "条目标识符snake_case例如 preferred_language") String key,
@ToolParam(description = "条目内容") String content) {
@ToolParam(description = "条目内容") String content,
ToolContext toolContext) {
if (agentId == null || type == null || key == null || content == null) {
return error("agentId, type, key, content 均不能为空");
@ -51,7 +72,7 @@ public class StructuredMemoryTool {
try {
structuredMemoryService.remember(agentId, type.trim().toLowerCase(),
key.trim(), content.trim(), "agent");
key.trim(), content.trim(), "agent", writeOwner(toolContext));
JSONObject result = new JSONObject();
result.set("success", true);
@ -75,7 +96,8 @@ public class StructuredMemoryTool {
public String recall_structured(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "记忆类型过滤可选user / feedback / project / reference", required = false) String type,
@ToolParam(description = "搜索关键词(可选),匹配 key 和内容", required = false) String keyword) {
@ToolParam(description = "搜索关键词(可选),匹配 key 和内容", required = false) String keyword,
ToolContext toolContext) {
if (agentId == null) {
return error("agentId 不能为空");
@ -85,7 +107,8 @@ public class StructuredMemoryTool {
List<Map<String, String>> results = structuredMemoryService.recall(
agentId,
type != null && !type.isBlank() ? type.trim().toLowerCase() : null,
keyword);
keyword,
readOwner(toolContext));
JSONObject result = new JSONObject();
result.set("agentId", agentId);
@ -107,7 +130,8 @@ public class StructuredMemoryTool {
public String forget_structured(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "记忆类型user / feedback / project / reference") String type,
@ToolParam(description = "要删除的条目标识符") String key) {
@ToolParam(description = "要删除的条目标识符") String key,
ToolContext toolContext) {
if (agentId == null || type == null || key == null) {
return error("agentId, type, key 均不能为空");
@ -115,7 +139,7 @@ public class StructuredMemoryTool {
try {
boolean removed = structuredMemoryService.forget(agentId,
type.trim().toLowerCase(), key.trim());
type.trim().toLowerCase(), key.trim(), writeOwner(toolContext));
JSONObject result = new JSONObject();
result.set("success", removed);

View File

@ -4,11 +4,15 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.memory.MemoryProperties;
import vip.mate.memory.event.MemoryWriteEvent;
import vip.mate.memory.identity.MemoryOwnerResolver;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
import vip.mate.workspace.document.WorkspaceFileService;
@ -41,6 +45,8 @@ public class UniversalMemoryTool {
private final WorkspaceFileService workspaceFileService;
private final ApplicationEventPublisher eventPublisher;
private final MemoryOwnerResolver memoryOwnerResolver;
private final MemoryProperties memoryProperties;
@Tool(description = """
将一条自由形式的经验或洞察追加到 Agent 的长期记忆 (MEMORY.md)
@ -51,17 +57,24 @@ public class UniversalMemoryTool {
public String remember(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "要记住的内容(自由形式)") String content,
@ToolParam(description = "可选来源上下文skill 名 / conversation id", required = false) String source) {
@ToolParam(description = "可选来源上下文skill 名 / conversation id", required = false) String source,
ToolContext toolContext) {
if (agentId == null) return error("agentId 不能为空");
if (content == null || content.isBlank()) return error("content 不能为空");
try {
WorkspaceFileEntity existing = workspaceFileService.getFile(agentId, MEMORY_FILENAME);
// Write to the requester's PERSONAL MEMORY.md when per-owner isolation
// is active; otherwise the shared file (so the note is not stranded
// in an un-read PERSONAL row).
String ownerKey = memoryProperties.isLifecycleMediatorEnabled()
? memoryOwnerResolver.resolve(ChatOrigin.from(toolContext))
: null;
WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(agentId, MEMORY_FILENAME, ownerKey);
String existingContent = existing != null && existing.getContent() != null
? existing.getContent() : "";
String updated = appendLesson(existingContent, content, source);
workspaceFileService.saveFile(agentId, MEMORY_FILENAME, updated);
workspaceFileService.saveVisibleFile(agentId, MEMORY_FILENAME, updated, ownerKey);
// RFC-090 §14.3 universal remember() targets MEMORY.md (the
// canonical file), so this IS a MemoryWriteEvent. Skill-local

View File

@ -5,9 +5,13 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.memory.MemoryProperties;
import vip.mate.memory.identity.MemoryOwnerResolver;
import vip.mate.memory.service.MemoryRecallTracker;
import vip.mate.workspace.document.MemorySearchHit;
import vip.mate.workspace.document.WorkspaceFileService;
@ -32,6 +36,22 @@ public class WorkspaceMemoryTool {
private final WorkspaceFileService workspaceFileService;
private final MemoryRecallTracker memoryRecallTracker;
private final MemoryOwnerResolver memoryOwnerResolver;
private final MemoryProperties memoryProperties;
/** Owner key for reads: always the resolved requester (visibility = shared + own personal). */
private String readOwner(ToolContext ctx) {
return memoryOwnerResolver.resolve(ChatOrigin.from(ctx));
}
/**
* Owner key for writes: the resolved requester only when per-owner isolation
* is active (lifecycle prefetch on); otherwise null so the write lands in
* the shared bucket and is not stranded in an un-read PERSONAL row.
*/
private String writeOwner(ToolContext ctx) {
return memoryProperties.isLifecycleMediatorEnabled() ? readOwner(ctx) : null;
}
@Tool(description = """
列出指定 Agent 的数据库工作区记忆文件
@ -40,13 +60,14 @@ public class WorkspaceMemoryTool {
""")
public String list_workspace_memory_files(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "可选:按文件名前缀过滤,例如 memory/ 或 MEM", required = false) String filenamePrefix) {
@ToolParam(description = "可选:按文件名前缀过滤,例如 memory/ 或 MEM", required = false) String filenamePrefix,
ToolContext toolContext) {
if (agentId == null) {
return error("agentId 不能为空");
}
List<WorkspaceFileEntity> files = workspaceFileService.listFiles(agentId).stream()
List<WorkspaceFileEntity> files = workspaceFileService.listVisibleFiles(agentId, readOwner(toolContext)).stream()
.filter(file -> filenamePrefix == null || filenamePrefix.isBlank()
|| (file.getFilename() != null && file.getFilename().startsWith(filenamePrefix)))
.sorted(Comparator
@ -78,14 +99,15 @@ public class WorkspaceMemoryTool {
""")
public String read_workspace_memory_file(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename) {
@ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename,
ToolContext toolContext) {
String validation = validate(agentId, filename);
if (validation != null) {
return error(validation);
}
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
WorkspaceFileEntity file = workspaceFileService.getVisibleFile(agentId, filename, readOwner(toolContext));
if (file == null) {
return error("工作区文件不存在: " + filename);
}
@ -116,15 +138,17 @@ public class WorkspaceMemoryTool {
public String write_workspace_memory_file(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename,
@ToolParam(description = "要写入的完整 Markdown 内容") String content) {
@ToolParam(description = "要写入的完整 Markdown 内容") String content,
ToolContext toolContext) {
String validation = validate(agentId, filename);
if (validation != null) {
return error(validation);
}
WorkspaceFileEntity before = workspaceFileService.getFile(agentId, filename);
WorkspaceFileEntity saved = workspaceFileService.saveFile(agentId, filename, content != null ? content : "");
String ownerKey = writeOwner(toolContext);
WorkspaceFileEntity before = workspaceFileService.getVisibleFile(agentId, filename, ownerKey);
WorkspaceFileEntity saved = workspaceFileService.saveVisibleFile(agentId, filename, content != null ? content : "", ownerKey);
JSONObject result = new JSONObject();
result.set("agentId", agentId);
@ -149,7 +173,8 @@ public class WorkspaceMemoryTool {
@ToolParam(description = "工作区文件名,例如 MEMORY.md、PROFILE.md、memory/2026-03-31.md") String filename,
@ToolParam(description = "要查找的原始文本,要求精确匹配") String oldText,
@ToolParam(description = "替换后的新文本") String newText,
@ToolParam(description = "是否替换全部匹配项,默认 false", required = false) Boolean replaceAll) {
@ToolParam(description = "是否替换全部匹配项,默认 false", required = false) Boolean replaceAll,
ToolContext toolContext) {
String validation = validate(agentId, filename);
if (validation != null) {
@ -165,7 +190,8 @@ public class WorkspaceMemoryTool {
return error("oldText 和 newText 相同,无需替换");
}
WorkspaceFileEntity existing = workspaceFileService.getFile(agentId, filename);
String ownerKey = writeOwner(toolContext);
WorkspaceFileEntity existing = workspaceFileService.getVisibleFile(agentId, filename, ownerKey);
if (existing == null) {
return error("工作区文件不存在: " + filename);
}
@ -187,7 +213,7 @@ public class WorkspaceMemoryTool {
replacements = 1;
}
workspaceFileService.saveFile(agentId, filename, updated);
workspaceFileService.saveVisibleFile(agentId, filename, updated, ownerKey);
JSONObject result = new JSONObject();
result.set("agentId", agentId);
@ -211,7 +237,8 @@ public class WorkspaceMemoryTool {
@ToolParam(description = "关键词或短语2-64 字符") String query,
@ToolParam(description = "搜索范围all全部/ memoryMEMORY.md 与 memory// profile / persona默认 all",
required = false) String scope,
@ToolParam(description = "返回的最大命中数,默认 10上限 30", required = false) Integer limit) {
@ToolParam(description = "返回的最大命中数,默认 10上限 30", required = false) Integer limit,
ToolContext toolContext) {
if (agentId == null) {
return error("agentId 不能为空");
@ -230,8 +257,11 @@ public class WorkspaceMemoryTool {
int effectiveLimit = limit == null ? 10 : Math.min(Math.max(limit, 1), 30);
Set<String> prefixes = resolveScope(scope);
// Restrict hits to memory the current requester may see: shared memory
// plus this owner's PERSONAL memory only.
String ownerKey = readOwner(toolContext);
List<MemorySearchHit> hits = workspaceFileService.searchSnippets(
agentId, trimmed, prefixes, effectiveLimit);
agentId, trimmed, prefixes, effectiveLimit, ownerKey);
// Treat each unique file in the results as an active retrieval signal
// boosts that file's weight in the dream-consolidation ranker the same
@ -239,7 +269,9 @@ public class WorkspaceMemoryTool {
Set<String> retrieved = new HashSet<>();
for (MemorySearchHit hit : hits) {
if (retrieved.add(hit.filename())) {
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, hit.filename());
// Read the same visible row the hit came from (the owner's
// PERSONAL row when present) so PERSONAL hits track correctly.
WorkspaceFileEntity file = workspaceFileService.getVisibleFile(agentId, hit.filename(), ownerKey);
if (file != null && file.getContent() != null) {
memoryRecallTracker.trackActiveRetrieval(agentId, hit.filename(), file.getContent());
}

View File

@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.memory.identity.MemoryScope;
import vip.mate.workspace.document.event.WorkspaceFileChangedEvent;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
import vip.mate.workspace.document.repository.WorkspaceFileMapper;
@ -50,19 +51,39 @@ public class WorkspaceFileService {
}
/**
* 读取单个文件含内容
* Read a single shared (config / persona) file by name.
* <p>
* Restricted to TEAM / GLOBAL scope so it never matches or accidentally
* mutates an owner's PERSONAL row that happens to share the same filename
* (e.g. MEMORY.md). Owner-scoped reads must use
* {@link #getMemoryFile(Long, String, String)}. Uses the non-throwing
* {@code selectOne(wrapper, false)} so duplicate rows can never surface a
* {@code TooManyResultsException}.
*/
public WorkspaceFileEntity getFile(Long agentId, String filename) {
return fileMapper.selectOne(
new LambdaQueryWrapper<WorkspaceFileEntity>()
.eq(WorkspaceFileEntity::getAgentId, agentId)
.eq(WorkspaceFileEntity::getFilename, filename));
.eq(WorkspaceFileEntity::getFilename, filename)
.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
.orderByAsc(WorkspaceFileEntity::getId),
false);
}
/**
* 创建或更新文件
* 创建或更新文件共享 / 配置文件路径
* <p>
* Used by the agent-config surface (AGENTS.md, SOUL.md, PROFILE.md ).
* Rows written here are TEAM-scoped and shared by everyone using the agent.
* Conversation-derived memory must go through
* {@link #saveMemoryFile(Long, String, String, String)} instead so it is
* attributed to a single owner.
*/
@Transactional
// NOTE: intentionally NOT @Transactional. These are single-row upserts and
// the dup-key fallback below reselects+updates after a failed insert under
// a transaction the failed insert would mark it rollback-only and poison the
// recovery update. WorkspaceFileChangedEvent uses a plain @EventListener
// (not @TransactionalEventListener), so event timing is unaffected.
public WorkspaceFileEntity saveFile(Long agentId, String filename, String content) {
WorkspaceFileEntity existing = getFile(agentId, filename);
long size = content != null ? content.getBytes(StandardCharsets.UTF_8).length : 0;
@ -73,29 +94,200 @@ public class WorkspaceFileService {
fileMapper.updateById(existing);
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename));
return existing;
} else {
WorkspaceFileEntity entity = new WorkspaceFileEntity();
entity.setAgentId(agentId);
entity.setFilename(filename);
entity.setContent(content);
entity.setFileSize(size);
entity.setEnabled(false);
entity.setSortOrder(0);
fileMapper.insert(entity);
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename));
return entity;
}
WorkspaceFileEntity entity = new WorkspaceFileEntity();
entity.setAgentId(agentId);
entity.setFilename(filename);
entity.setContent(content);
entity.setFileSize(size);
entity.setEnabled(false);
entity.setSortOrder(0);
entity.setScope(MemoryScope.TEAM);
// Shared rows use the empty-string sentinel (not NULL) so the
// (agent_id, filename, owner_key) unique index treats one shared
// row per filename as a single slot NULLs are considered distinct
// by both H2 and MySQL unique indexes and would not be deduped.
entity.setOwnerKey(SHARED_OWNER_KEY);
WorkspaceFileEntity saved = insertOrUpdateOnConflict(
entity, () -> getFile(agentId, filename), content, size);
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename));
return saved;
}
/**
* 删除文件
* Insert {@code entity}; if a concurrent writer already created the row
* (unique-index violation), reselect via {@code reselect} and update its
* content instead of throwing. Makes the check-then-insert in
* saveFile / saveMemoryFile safe under concurrent / multi-node first writes.
*/
private WorkspaceFileEntity insertOrUpdateOnConflict(WorkspaceFileEntity entity,
java.util.function.Supplier<WorkspaceFileEntity> reselect,
String content, long size) {
try {
fileMapper.insert(entity);
return entity;
} catch (org.springframework.dao.DuplicateKeyException dup) {
// Only recover the specific concurrent first-write race on the
// owner-scope unique index. Any other duplicate (e.g. a primary-key
// collision, or a future unique constraint) must surface silently
// reselecting+updating would mask a real bug. The driver message
// names the violated index on both MySQL and H2.
String msg = dup.getMessage();
if (msg == null || !msg.toLowerCase().contains(UK_OWNER_INDEX)) {
throw dup;
}
WorkspaceFileEntity raced = reselect.get();
if (raced != null) {
raced.setContent(content);
raced.setFileSize(size);
fileMapper.updateById(raced);
return raced;
}
throw dup;
}
}
/** Name of the (agent_id, filename, owner_key) unique index — see V137 migration. */
private static final String UK_OWNER_INDEX = "uk_workspace_file_owner";
/** Sentinel owner key for shared (TEAM / GLOBAL) rows — keeps the unique index effective. */
static final String SHARED_OWNER_KEY = "";
/** A real, isolatable owner — not null/blank and not the system bucket. */
private boolean isPersonalOwner(String ownerKey) {
return ownerKey != null && !ownerKey.isBlank()
&& !vip.mate.memory.identity.MemoryOwnerResolver.SYSTEM_OWNER.equals(ownerKey);
}
/**
* List files visible to {@code ownerKey}: shared (TEAM / GLOBAL) rows plus
* this owner's PERSONAL rows. A null/blank/system ownerKey lists shared
* only. Content is stripped (metadata listing).
*/
public List<WorkspaceFileEntity> listVisibleFiles(Long agentId, String ownerKey) {
LambdaQueryWrapper<WorkspaceFileEntity> wrapper = new LambdaQueryWrapper<WorkspaceFileEntity>()
.eq(WorkspaceFileEntity::getAgentId, agentId);
applyScopeVisibility(wrapper, isPersonalOwner(ownerKey) ? ownerKey : null);
wrapper.orderByAsc(WorkspaceFileEntity::getSortOrder)
.orderByAsc(WorkspaceFileEntity::getFilename);
List<WorkspaceFileEntity> files = fileMapper.selectList(wrapper);
files.forEach(f -> f.setContent(null));
return files;
}
/**
* Read a file visible to {@code ownerKey}: the owner's PERSONAL row when it
* exists, otherwise the shared row. Null when neither exists.
*/
public WorkspaceFileEntity getVisibleFile(Long agentId, String filename, String ownerKey) {
if (isPersonalOwner(ownerKey)) {
WorkspaceFileEntity personal = getMemoryFile(agentId, filename, ownerKey);
if (personal != null) {
return personal;
}
}
return getFile(agentId, filename);
}
/**
* Save a file to the owner's PERSONAL bucket when {@code ownerKey} denotes a
* real owner, otherwise to the shared (TEAM) file. The single entry point
* tools should use so per-owner isolation and the shared fallback stay
* consistent.
*/
public WorkspaceFileEntity saveVisibleFile(Long agentId, String filename, String content, String ownerKey) {
return isPersonalOwner(ownerKey)
? saveMemoryFile(agentId, filename, content, ownerKey)
: saveFile(agentId, filename, content);
}
/**
* Read a memory file scoped to a single owner.
* <p>
* Daily ledgers and consolidated memory share a filename across owners
* (e.g. {@code memory/2026-06-02.md}), so the lookup key is
* {@code (agentId, filename, ownerKey)} otherwise two end-users sharing
* one agent would clobber each other's row.
*/
public WorkspaceFileEntity getMemoryFile(Long agentId, String filename, String ownerKey) {
return fileMapper.selectOne(
new LambdaQueryWrapper<WorkspaceFileEntity>()
.eq(WorkspaceFileEntity::getAgentId, agentId)
.eq(WorkspaceFileEntity::getFilename, filename)
.eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL)
.eq(WorkspaceFileEntity::getOwnerKey, ownerKey)
.orderByAsc(WorkspaceFileEntity::getId),
false);
}
/**
* Create or update a PERSONAL, owner-scoped memory file.
* <p>
* Rows written here carry {@code scope=PERSONAL} + {@code ownerKey} and are
* enabled so the per-turn memory injection ({@code prefetch}) picks them up
* for that owner only.
*/
// NOTE: intentionally NOT @Transactional see saveFile for the dup-key
// recovery rationale.
public WorkspaceFileEntity saveMemoryFile(Long agentId, String filename, String content, String ownerKey) {
WorkspaceFileEntity existing = getMemoryFile(agentId, filename, ownerKey);
long size = content != null ? content.getBytes(StandardCharsets.UTF_8).length : 0;
if (existing != null) {
existing.setContent(content);
existing.setFileSize(size);
fileMapper.updateById(existing);
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename));
return existing;
}
WorkspaceFileEntity entity = new WorkspaceFileEntity();
entity.setAgentId(agentId);
entity.setFilename(filename);
entity.setContent(content);
entity.setFileSize(size);
entity.setEnabled(true);
entity.setSortOrder(0);
entity.setOwnerKey(ownerKey);
entity.setScope(MemoryScope.PERSONAL);
WorkspaceFileEntity saved = insertOrUpdateOnConflict(
entity, () -> getMemoryFile(agentId, filename, ownerKey), content, size);
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename));
return saved;
}
/**
* Delete a shared (config / persona) file by name.
* <p>
* Scoped to TEAM / GLOBAL so the config-editor surface can never wipe every
* owner's same-named PERSONAL row (e.g. all users' {@code MEMORY.md}).
* Owner-scoped deletion goes through
* {@link #deleteMemoryFile(Long, String, String)}.
*/
@Transactional
public void deleteFile(Long agentId, String filename) {
fileMapper.delete(
new LambdaQueryWrapper<WorkspaceFileEntity>()
.eq(WorkspaceFileEntity::getAgentId, agentId)
.eq(WorkspaceFileEntity::getFilename, filename));
.eq(WorkspaceFileEntity::getFilename, filename)
.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL));
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename));
}
/**
* Delete a single owner's PERSONAL file by name. Only ever removes the row
* belonging to {@code ownerKey}, never another owner's or the shared row.
*/
@Transactional
public void deleteMemoryFile(Long agentId, String filename, String ownerKey) {
if (!isPersonalOwner(ownerKey)) {
return;
}
fileMapper.delete(
new LambdaQueryWrapper<WorkspaceFileEntity>()
.eq(WorkspaceFileEntity::getAgentId, agentId)
.eq(WorkspaceFileEntity::getFilename, filename)
.eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL)
.eq(WorkspaceFileEntity::getOwnerKey, ownerKey));
eventPublisher.publishEvent(new WorkspaceFileChangedEvent(agentId, filename));
}
@ -107,6 +299,10 @@ public class WorkspaceFileService {
new LambdaQueryWrapper<WorkspaceFileEntity>()
.eq(WorkspaceFileEntity::getAgentId, agentId)
.eq(WorkspaceFileEntity::getEnabled, true)
// System-prompt file management operates on shared config
// files only; PERSONAL memory rows (enabled by default)
// must never appear in or be toggled by this surface.
.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
.orderByAsc(WorkspaceFileEntity::getSortOrder))
.stream()
.map(WorkspaceFileEntity::getFilename)
@ -121,9 +317,14 @@ public class WorkspaceFileService {
*/
@Transactional
public void setPromptFiles(Long agentId, List<String> filenames) {
// Only shared config files participate in system-prompt enable/disable.
// PERSONAL memory rows are enabled per-owner by saveMemoryFile and must
// not be batch-toggled by filename here (that would flip every owner's
// row sharing that filename).
List<WorkspaceFileEntity> allFiles = fileMapper.selectList(
new LambdaQueryWrapper<WorkspaceFileEntity>()
.eq(WorkspaceFileEntity::getAgentId, agentId));
.eq(WorkspaceFileEntity::getAgentId, agentId)
.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL));
for (WorkspaceFileEntity file : allFiles) {
int index = filenames.indexOf(file.getFilename());
@ -208,6 +409,20 @@ public class WorkspaceFileService {
*/
public List<MemorySearchHit> searchSnippets(Long agentId, String query,
Set<String> filenamePrefixes, int limit) {
return searchSnippets(agentId, query, filenamePrefixes, limit, null);
}
/**
* Owner-scoped overload of {@link #searchSnippets(Long, String, Set, int)}.
* <p>
* Restricts candidates to memory the given {@code ownerKey} may see:
* shared rows (TEAM / GLOBAL) plus this owner's own PERSONAL rows. A null
* {@code ownerKey} means "shared only" used by legacy call sites that
* have no requester identity in scope.
*/
public List<MemorySearchHit> searchSnippets(Long agentId, String query,
Set<String> filenamePrefixes, int limit,
String ownerKey) {
if (agentId == null || limit <= 0) {
return List.of();
}
@ -221,6 +436,7 @@ public class WorkspaceFileService {
LambdaQueryWrapper<WorkspaceFileEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(WorkspaceFileEntity::getAgentId, agentId);
applyScopeVisibility(wrapper, ownerKey);
if (filenamePrefixes != null && !filenamePrefixes.isEmpty()) {
// Group prefix conditions inside a single AND-bracketed OR chain
@ -433,24 +649,71 @@ public class WorkspaceFileService {
}
/**
* 将启用的工作区文件拼接为系统提示词
* Restrict a query to memory visible to {@code ownerKey}: shared rows
* (TEAM / GLOBAL) always, plus this owner's own PERSONAL rows. When
* {@code ownerKey} is null/blank only shared rows are returned.
*/
private void applyScopeVisibility(LambdaQueryWrapper<WorkspaceFileEntity> wrapper, String ownerKey) {
if (ownerKey == null || ownerKey.isBlank()) {
wrapper.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL);
return;
}
wrapper.and(w -> w
.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
.or(p -> p.eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL)
.eq(WorkspaceFileEntity::getOwnerKey, ownerKey)));
}
/**
* 将启用的共享TEAM / GLOBAL工作区文件拼接为系统提示词
* <p>
* 每个文件以 "--- {filename} ---\n{content}\n" 的格式拼接
* 如果没有启用的文件返回 null
* <p>
* Baked once at agent build time and shared by every requester, so this
* deliberately excludes PERSONAL rows per-owner memory is injected
* per-turn via {@link #buildOwnerMemoryBlock(Long, String)} instead.
*/
public String buildSystemPrompt(Long agentId) {
List<WorkspaceFileEntity> enabledFiles = fileMapper.selectList(
new LambdaQueryWrapper<WorkspaceFileEntity>()
.eq(WorkspaceFileEntity::getAgentId, agentId)
.eq(WorkspaceFileEntity::getEnabled, true)
.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
.orderByAsc(WorkspaceFileEntity::getSortOrder));
if (enabledFiles.isEmpty()) {
return concatFiles(enabledFiles);
}
/**
* Assemble the per-owner memory block injected before each LLM call.
* <p>
* Returns the enabled PERSONAL files belonging to {@code ownerKey} (the
* owner's consolidated MEMORY.md / PROFILE.md and any enabled daily notes),
* concatenated in the same format as {@link #buildSystemPrompt(Long)}.
* Null when the owner has no personal memory yet.
*/
public String buildOwnerMemoryBlock(Long agentId, String ownerKey) {
if (agentId == null || ownerKey == null || ownerKey.isBlank()) {
return null;
}
List<WorkspaceFileEntity> files = fileMapper.selectList(
new LambdaQueryWrapper<WorkspaceFileEntity>()
.eq(WorkspaceFileEntity::getAgentId, agentId)
.eq(WorkspaceFileEntity::getEnabled, true)
.eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL)
.eq(WorkspaceFileEntity::getOwnerKey, ownerKey)
.orderByAsc(WorkspaceFileEntity::getSortOrder));
return concatFiles(files);
}
/** Concatenate file bodies in the "--- {filename} ---\n{content}" format. */
private String concatFiles(List<WorkspaceFileEntity> files) {
if (files == null || files.isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder();
for (WorkspaceFileEntity file : enabledFiles) {
for (WorkspaceFileEntity file : files) {
if (file.getContent() != null && !file.getContent().isBlank()) {
if (!sb.isEmpty()) {
sb.append("\n\n");

View File

@ -42,7 +42,9 @@ public class WorkspaceFileController {
@RequireWorkspaceRole("viewer")
@GetMapping("/files")
public R<List<WorkspaceFileEntity>> listFiles(@PathVariable Long agentId) {
return R.ok(workspaceFileService.listFiles(agentId));
// Config-editor surface: shared (TEAM/GLOBAL) files only. Per-owner
// PERSONAL memory rows are never exposed or managed through this REST API.
return R.ok(workspaceFileService.listVisibleFiles(agentId, null));
}
/**

View File

@ -36,6 +36,20 @@ public class WorkspaceFileEntity {
/** 排序顺序(越小越靠前) */
private Integer sortOrder;
/**
* Memory subject this row belongs to, as a prefixed string
* ("user:42", "feishu:ou_xxx", "api:&lt;endUserId&gt;"). Null for shared
* config rows (AGENTS.md / SOUL.md / PROFILE.md) and legacy data.
*/
private String ownerKey;
/**
* Visibility scope: PERSONAL (only the matching {@link #ownerKey} sees it),
* TEAM (everyone using the agent), or GLOBAL (always visible). Defaults to
* TEAM at the DB level so config files and legacy rows stay shared.
*/
private String scope;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -0,0 +1,55 @@
-- V137: Per-owner memory isolation with a three-state visibility scope.
--
-- Adds owner_key + scope to the three memory-bearing tables so a single agent
-- shared across multiple end-users (web users, IM senders, third-party API
-- end-users) keeps each owner's memory separate.
--
-- owner_key - the memory subject this row belongs to, as a prefixed string
-- ("user:42", "feishu:ou_xxx", "api:<endUserId>"). NULL means
-- "not owner-scoped" (legacy / shared config rows).
-- scope - PERSONAL (only the matching owner_key sees it),
-- TEAM (everyone using the agent sees it),
-- GLOBAL (always visible).
--
-- Existing rows are backfilled to scope='TEAM' by the NOT NULL DEFAULT so that
-- upgrading does NOT hide previously-shared memory. New memory writes set
-- scope='PERSONAL' with the resolved owner_key; agent config files (AGENTS.md,
-- SOUL.md, PROFILE.md) keep the TEAM default and stay shared.
ALTER TABLE mate_workspace_file ADD COLUMN IF NOT EXISTS owner_key VARCHAR(128) NULL;
ALTER TABLE mate_workspace_file ADD COLUMN IF NOT EXISTS scope VARCHAR(16) NOT NULL DEFAULT 'TEAM';
CREATE INDEX IF NOT EXISTS idx_workspace_file_scope_owner ON mate_workspace_file(agent_id, scope, owner_key);
-- Shared rows use the '' sentinel (not NULL) so the unique index below treats
-- one shared row per filename as a single slot.
UPDATE mate_workspace_file SET owner_key = '' WHERE owner_key IS NULL;
-- De-duplicate before adding the unique index: the table never had a unique
-- constraint and the service layer was check-then-insert, so historical
-- duplicates may exist. Keep the most recently inserted row per
-- (agent_id, filename, owner_key); drop the rest so the index can be created.
--
-- IRREVERSIBLE: this keeps MAX(id) (newest row) and PERMANENTLY deletes the
-- other rows in a duplicate group — their content / enabled / sort_order are
-- not preserved or merged. Duplicates are NOT expected (every write path is
-- check-then-insert), so this is a safety net to guarantee the index builds,
-- not a routine merge. If a deployment knowingly relies on duplicate rows,
-- reconcile them manually before upgrading.
DELETE FROM mate_workspace_file
WHERE id NOT IN (
SELECT keep_id FROM (
SELECT MAX(id) AS keep_id
FROM mate_workspace_file
GROUP BY agent_id, filename, owner_key
) t
);
-- One row per (agent, filename, owner): one shared row + one row per PERSONAL
-- owner. Hardens the check-then-insert in saveFile/saveMemoryFile against
-- concurrent / multi-node duplicates.
CREATE UNIQUE INDEX IF NOT EXISTS uk_workspace_file_owner ON mate_workspace_file(agent_id, filename, owner_key);
ALTER TABLE mate_memory_recall ADD COLUMN IF NOT EXISTS owner_key VARCHAR(128) NULL;
ALTER TABLE mate_memory_recall ADD COLUMN IF NOT EXISTS scope VARCHAR(16) NOT NULL DEFAULT 'TEAM';
CREATE INDEX IF NOT EXISTS idx_memory_recall_scope_owner ON mate_memory_recall(agent_id, scope, owner_key);
ALTER TABLE mate_fact ADD COLUMN IF NOT EXISTS owner_key VARCHAR(128) NULL;
ALTER TABLE mate_fact ADD COLUMN IF NOT EXISTS scope VARCHAR(16) NOT NULL DEFAULT 'TEAM';
CREATE INDEX IF NOT EXISTS idx_fact_scope_owner ON mate_fact(agent_id, scope, owner_key);

View File

@ -0,0 +1,109 @@
-- V137: Per-owner memory isolation with a three-state visibility scope (MySQL).
--
-- See the H2 counterpart for the full rationale. MySQL has no
-- "ADD COLUMN IF NOT EXISTS", so each column/index is guarded with an
-- INFORMATION_SCHEMA existence check + prepared statement for idempotency.
--
-- Existing rows are backfilled to scope='TEAM' by the NOT NULL DEFAULT so that
-- upgrading does NOT hide previously-shared memory.
-- ---------- mate_workspace_file ----------
SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_workspace_file' AND COLUMN_NAME = 'owner_key');
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_workspace_file ADD COLUMN owner_key VARCHAR(128) NULL',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_workspace_file' AND COLUMN_NAME = 'scope');
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_workspace_file ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT ''TEAM''',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_workspace_file' AND INDEX_NAME = 'idx_workspace_file_scope_owner');
SET @stmt := IF(@idx_exists = 0,
'CREATE INDEX idx_workspace_file_scope_owner ON mate_workspace_file(agent_id, scope, owner_key)',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
-- Shared rows use the '' sentinel (not NULL) so the unique index below treats
-- one shared row per filename as a single slot (NULLs are distinct in unique indexes).
UPDATE mate_workspace_file SET owner_key = '' WHERE owner_key IS NULL;
-- De-duplicate before adding the unique index: the table never had a unique
-- constraint and the service layer was check-then-insert, so historical
-- duplicates may exist. Keep the most recently inserted row per
-- (agent_id, filename, owner_key); drop the rest. The extra derived-table wrap
-- is required so MySQL doesn't reject selecting from the table being deleted.
--
-- IRREVERSIBLE: this keeps MAX(id) (newest row) and PERMANENTLY deletes the
-- other rows in a duplicate group — their content / enabled / sort_order are
-- not preserved or merged. Duplicates are NOT expected (every write path is
-- check-then-insert), so this is a safety net to guarantee the index builds,
-- not a routine merge. If a deployment knowingly relies on duplicate rows,
-- reconcile them manually before upgrading.
DELETE FROM mate_workspace_file
WHERE id NOT IN (
SELECT keep_id FROM (
SELECT MAX(id) AS keep_id
FROM mate_workspace_file
GROUP BY agent_id, filename, owner_key
) t
);
-- One row per (agent, filename, owner): one shared row + one row per PERSONAL
-- owner. Hardens the check-then-insert in saveFile/saveMemoryFile against
-- concurrent / multi-node duplicates.
SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_workspace_file' AND INDEX_NAME = 'uk_workspace_file_owner');
SET @stmt := IF(@idx_exists = 0,
'CREATE UNIQUE INDEX uk_workspace_file_owner ON mate_workspace_file(agent_id, filename, owner_key)',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
-- ---------- mate_memory_recall ----------
SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_memory_recall' AND COLUMN_NAME = 'owner_key');
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_memory_recall ADD COLUMN owner_key VARCHAR(128) NULL',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_memory_recall' AND COLUMN_NAME = 'scope');
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_memory_recall ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT ''TEAM''',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_memory_recall' AND INDEX_NAME = 'idx_memory_recall_scope_owner');
SET @stmt := IF(@idx_exists = 0,
'CREATE INDEX idx_memory_recall_scope_owner ON mate_memory_recall(agent_id, scope, owner_key)',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
-- ---------- mate_fact ----------
SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_fact' AND COLUMN_NAME = 'owner_key');
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_fact ADD COLUMN owner_key VARCHAR(128) NULL',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_fact' AND COLUMN_NAME = 'scope');
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_fact ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT ''TEAM''',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_fact' AND INDEX_NAME = 'idx_fact_scope_owner');
SET @stmt := IF(@idx_exists = 0,
'CREATE INDEX idx_fact_scope_owner ON mate_fact(agent_id, scope, owner_key)',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;

View File

@ -56,7 +56,7 @@ class LifecycleFlagGuardTest {
new ConversationCompletedEvent(1L, "conv-" + i, "hello", "reply", 5, "web"));
}
verify(memoryManager, never()).prefetchAll(any(), any());
verify(memoryManager, never()).prefetchAll(any(), any(), any());
verify(memoryManager, never()).syncAll(any(), any(), any(), any());
verify(memoryManager, never()).onSessionEnd(any(), any());
}
@ -68,11 +68,11 @@ class LifecycleFlagGuardTest {
// But MemoryLifecycleEventListener guards onSessionEnd.
props.setLifecycleMediatorEnabled(false);
when(memoryManager.prefetchAll(eq(1L), eq("q"))).thenReturn("");
when(memoryManager.prefetchAll(eq(1L), eq("q"), any())).thenReturn("");
// Direct mediator call works (AgentService would not call this when flag is off)
mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q"));
verify(memoryManager, times(1)).prefetchAll(1L, "q");
verify(memoryManager, times(1)).prefetchAll(eq(1L), eq("q"), any());
}
// ==================== Flag ON ====================
@ -81,13 +81,13 @@ class LifecycleFlagGuardTest {
@DisplayName("Flag ON: beforeLlmCall invokes prefetchAll")
void flagOn_prefetchAll() {
props.setLifecycleMediatorEnabled(true);
when(memoryManager.prefetchAll(eq(1L), eq("hello"))).thenReturn("");
when(memoryManager.prefetchAll(eq(1L), eq("hello"), any())).thenReturn("");
for (int i = 0; i < 10; i++) {
mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", i, "hello"));
}
verify(memoryManager, times(10)).prefetchAll(1L, "hello");
verify(memoryManager, times(10)).prefetchAll(eq(1L), eq("hello"), any());
}
@Test
@ -131,7 +131,7 @@ class LifecycleFlagGuardTest {
@Test
@DisplayName("Provider exception in prefetchAll degrades gracefully (returns empty)")
void prefetchException_graceful() {
when(memoryManager.prefetchAll(any(), any())).thenThrow(new RuntimeException("boom"));
when(memoryManager.prefetchAll(any(), any(), any())).thenThrow(new RuntimeException("boom"));
String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q"));

View File

@ -52,7 +52,8 @@ class LifecycleRecallCountIT {
props = new MemoryProperties();
MemoryLifecycleMediator mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher);
agentService = new AgentService(agentMapper, agentGraphBuilder,
memoryRecallTracker, mediator, props, conversationMapper);
memoryRecallTracker, mediator, props,
new vip.mate.memory.identity.MemoryOwnerResolver(), conversationMapper);
// Stub agent resolution (lenient for structural-only tests)
AgentEntity entity = new AgentEntity();
@ -76,7 +77,7 @@ class LifecycleRecallCountIT {
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any());
// Mediator is not invoked when flag is off
verify(memoryManager, never()).prefetchAll(any(), any());
verify(memoryManager, never()).prefetchAll(any(), any(), any());
verify(memoryManager, never()).syncAll(any(), any(), any(), any());
}
@ -84,7 +85,7 @@ class LifecycleRecallCountIT {
@DisplayName("F4 regression: flag ON — trackRecalls still called exactly once per chat (not doubled)")
void flagOn_trackRecallsStillOncePerChat() {
props.setLifecycleMediatorEnabled(true);
when(memoryManager.prefetchAll(any(), any())).thenReturn("");
when(memoryManager.prefetchAll(any(), any(), any())).thenReturn("");
for (int i = 0; i < 10; i++) {
agentService.chat(1L, "msg-" + i, "conv-1");
@ -94,7 +95,7 @@ class LifecycleRecallCountIT {
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any());
// Mediator IS invoked
verify(memoryManager, times(10)).prefetchAll(eq(1L), any());
verify(memoryManager, times(10)).prefetchAll(eq(1L), any(), any());
verify(memoryManager, times(10)).syncAll(eq(1L), eq("conv-1"), any(), any());
}
@ -109,7 +110,7 @@ class LifecycleRecallCountIT {
// 5 rounds with flag ON
props.setLifecycleMediatorEnabled(true);
when(memoryManager.prefetchAll(any(), any())).thenReturn("");
when(memoryManager.prefetchAll(any(), any(), any())).thenReturn("");
for (int i = 0; i < 5; i++) {
agentService.chat(1L, "on-" + i, "conv-1");
}
@ -118,7 +119,7 @@ class LifecycleRecallCountIT {
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any());
// Mediator only called for the ON rounds
verify(memoryManager, times(5)).prefetchAll(eq(1L), any());
verify(memoryManager, times(5)).prefetchAll(eq(1L), any(), any());
}
@Test

View File

@ -41,14 +41,14 @@ class MemoryLifecycleMediatorTest {
@Test
@DisplayName("beforeLlmCall returns prefetchAll result and publishes TurnStartedEvent")
void beforeLlmCall_normalPath() {
when(memoryManager.prefetchAll(eq(1L), eq("hello")))
when(memoryManager.prefetchAll(eq(1L), eq("hello"), any()))
.thenReturn("<memory-context>some context</memory-context>");
TurnContext ctx = new TurnContext(1L, "c1", "s1", 1, "hello");
String result = mediator.beforeLlmCall(ctx);
assertEquals("<memory-context>some context</memory-context>", result);
verify(memoryManager).prefetchAll(1L, "hello");
verify(memoryManager).prefetchAll(eq(1L), eq("hello"), any());
ArgumentCaptor<Object> eventCaptor = ArgumentCaptor.forClass(Object.class);
verify(eventPublisher).publishEvent(eventCaptor.capture());
@ -59,7 +59,7 @@ class MemoryLifecycleMediatorTest {
@Test
@DisplayName("beforeLlmCall returns empty string when prefetchAll returns empty")
void beforeLlmCall_emptyPrefetch() {
when(memoryManager.prefetchAll(any(), any())).thenReturn("");
when(memoryManager.prefetchAll(any(), any(), any())).thenReturn("");
String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q"));
@ -95,7 +95,7 @@ class MemoryLifecycleMediatorTest {
@Test
@DisplayName("beforeLlmCall degrades to empty string when prefetchAll throws")
void beforeLlmCall_exceptionDegrades() {
when(memoryManager.prefetchAll(any(), any()))
when(memoryManager.prefetchAll(any(), any(), any()))
.thenThrow(new RuntimeException("provider down"));
String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q"));
@ -141,7 +141,7 @@ class MemoryLifecycleMediatorTest {
@Test
@DisplayName("Multiple sequential turns do not interfere (Mediator is stateless)")
void multipleTurns_noInterference() {
when(memoryManager.prefetchAll(any(), any())).thenReturn("");
when(memoryManager.prefetchAll(any(), any(), any())).thenReturn("");
for (int i = 0; i < 5; i++) {
TurnContext ctx = new TurnContext(1L, "c1", "s1", i, "msg-" + i);
@ -149,7 +149,7 @@ class MemoryLifecycleMediatorTest {
mediator.afterLlmCall(ctx, "reply-" + i);
}
verify(memoryManager, times(5)).prefetchAll(eq(1L), any());
verify(memoryManager, times(5)).prefetchAll(eq(1L), any(), any());
verify(memoryManager, times(5)).syncAll(eq(1L), eq("c1"), any(), any());
}
}

View File

@ -58,7 +58,8 @@ class WorkspaceMemorySearchTest {
@Test
@DisplayName("saveFile publishes a change event so the cached agent instance is invalidated")
void saveFilePublishesChangeEvent() {
when(fileMapper.selectOne(any())).thenReturn(null); // new file path
// getFile() now uses the non-throwing selectOne(wrapper, false) overload.
when(fileMapper.selectOne(any(), org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(null); // new file path
service.saveFile(1000000001L, "MEMORY.md", "## 稳定事实\n- 用户语言:简体中文");
@ -271,18 +272,77 @@ class WorkspaceMemorySearchTest {
assertThat(sql).contains("LIKE ? OR")
.contains("AND content")
.contains("LIMIT 50");
// With a null ownerKey the scope-visibility clause restricts to shared
// rows via an IN (?, ?) on (TEAM, GLOBAL), adding two bind params.
assertThat(sql.chars().filter(ch -> ch == '?').count())
.as("one agentId + two prefix LIKEs + three content LIKEs = 6 bind params")
.isEqualTo(6);
.as("agentId + two scope params + two prefix LIKEs + three content LIKEs = 8 bind params")
.isEqualTo(8);
List<Object> values = new ArrayList<>(wrapper.getParamNameValuePairs().values());
assertThat(values).contains(42L);
// Shared-scope visibility filter binds the TEAM / GLOBAL literals.
assertThat(values).contains("TEAM", "GLOBAL");
// Each content-LIKE term gets %term% by MyBatis-Plus's like().
assertThat(values).contains("%running%", "%shoes%", "%跑步%");
// likeRight produces "prefix%" confirms both prefixes were bound.
assertThat(values).contains("memory/%", "MEMORY.md%");
}
@Test
@DisplayName("Owner-scoped search binds shared (TEAM/GLOBAL) + this owner's PERSONAL rows only")
void ownerScopedSearchBindsPersonalBranch() {
when(fileMapper.selectList(any())).thenReturn(List.of());
service.searchSnippets(42L, "running", null, 10, "user:7");
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaQueryWrapper<WorkspaceFileEntity>> captor =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
org.mockito.Mockito.verify(fileMapper).selectList(captor.capture());
LambdaQueryWrapper<WorkspaceFileEntity> wrapper = captor.getValue();
wrapper.getTargetSql();
List<Object> values = new ArrayList<>(wrapper.getParamNameValuePairs().values());
// Visibility clause: (scope IN (TEAM, GLOBAL)) OR (scope = PERSONAL AND owner_key = ?)
assertThat(values).contains("TEAM", "GLOBAL", "PERSONAL", "user:7");
// A different owner's key must NOT be bound that is the isolation guarantee.
assertThat(values).doesNotContain("user:99");
}
@Test
@DisplayName("saveFile recovers from a concurrent first-write unique conflict by reselect+update")
void saveFileRecoversFromConcurrentInsert() {
WorkspaceFileEntity raced = file("MEMORY.md", "racer content");
// First selectOne (existence check) null (we think it's new); after the
// insert loses the unique-index race, the reselect the racer's row.
when(fileMapper.selectOne(any(), org.mockito.ArgumentMatchers.anyBoolean()))
.thenReturn(null, raced);
when(fileMapper.insert(any(WorkspaceFileEntity.class)))
.thenThrow(new org.springframework.dao.DuplicateKeyException("uk_workspace_file_owner"));
service.saveFile(1000000001L, "MEMORY.md", "## new");
// Must fall back to updating the existing row, not propagate the exception.
org.mockito.Mockito.verify(fileMapper).updateById(raced);
assertThat(raced.getContent()).isEqualTo("## new");
}
@Test
@DisplayName("saveFile rethrows a duplicate-key error unrelated to the owner-scope index (no false recovery)")
void saveFileRethrowsUnrelatedDuplicateKey() {
when(fileMapper.selectOne(any(), org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(null);
// A PRIMARY-key collision (not the uk_workspace_file_owner index) must
// NOT be swallowed as a concurrent first-write.
when(fileMapper.insert(any(WorkspaceFileEntity.class)))
.thenThrow(new org.springframework.dao.DuplicateKeyException(
"Duplicate entry '42' for key 'PRIMARY'"));
org.assertj.core.api.Assertions.assertThatThrownBy(
() -> service.saveFile(1000000001L, "MEMORY.md", "## new"))
.isInstanceOf(org.springframework.dao.DuplicateKeyException.class);
org.mockito.Mockito.verify(fileMapper, org.mockito.Mockito.never())
.updateById(any(WorkspaceFileEntity.class));
}
// ---------- helpers ----------
private static WorkspaceFileEntity file(String filename, String content) {