mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(memory): per-owner memory isolation with owner_key + visibility scope (#235)
This commit is contained in:
parent
40ce1c67ac
commit
48611a6f4d
@ -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();
|
||||
|
||||
@ -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 渠道专用,不走队列) ====================
|
||||
|
||||
@ -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");
|
||||
|
||||
// 使用虚拟线程池处理 SSE(Java 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 > 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).
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(全部)/ memory(MEMORY.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());
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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:<endUserId>"). 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;
|
||||
|
||||
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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"));
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user