mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(memory): Dream v2 Phase 1 — lifecycle mediator foundation
Wire memory-facing events (turn-started, turn-completed, session-ended, memory-written) through a single MemoryLifecycleMediator so MemoryProvider implementations can hook into the agent conversational flow without spreading side-effects across the runtime. Ten atomic steps shipped under feat/dream-v2-p1-lifecycle: - A.1 + A.2: MemoryLifecycleMediator class + TurnContext value object - A.3: TurnStartedEvent / TurnCompletedEvent domain events - A.4: MemoryLifecycleEventListener bean for Spring event plumbing - A.5: MemoryProvider.onMemoryWrite default method (backward compatible) - A.7: wire the mediator into AgentService at the right hook points - A.8: LifecycleFlagGuardTest — feature flag must gate every hook - A.9: MemoryLifecycleMediatorTest — unit coverage per hook - A.10: LifecycleRecallCountIT — F4 regression across the stack Feature flags (all default OFF; enable per phase after staging): - mate.memory.lifecycle-mediator-enabled - mate.memory.dream.focused-enabled - mate.memory.dream.archive-enabled This is Phase 1 foundation only — focused-dream and archive-dream providers arrive in later phases.
This commit is contained in:
parent
e99165f346
commit
74928d615d
11
.github/pull_request_template.md
vendored
Normal file
11
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
## Dream v2 PR Checklist
|
||||
|
||||
- [ ] Only modifies current Phase scope; no future Phase undecided designs introduced
|
||||
- [ ] Feature flag defaults to off
|
||||
- [ ] `scripts/rfc-lint.sh` passes locally
|
||||
- [ ] New Flyway migrations exist in both h2/ and mysql/ directories
|
||||
- [ ] New `@Scheduled` cron expressions documented in application.yml with stagger rationale
|
||||
- [ ] Phase 1 PR: `recall_count / daily_count` regression test passes
|
||||
- [ ] Phase 3 PR: `mate_fact` two-write-path SQL-level guard test passes
|
||||
|
||||
Related RFC: <!-- rfc-035 P1-S{x} / rfc-037 P1-S{x} / ... -->
|
||||
@ -11,11 +11,16 @@ import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.event.ModelConfigChangedEvent;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
|
||||
import vip.mate.memory.lifecycle.TurnContext;
|
||||
import vip.mate.memory.service.MemoryRecallTracker;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Agent 业务服务
|
||||
@ -33,6 +38,8 @@ public class AgentService {
|
||||
private final AgentMapper agentMapper;
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final MemoryRecallTracker memoryRecallTracker;
|
||||
private final MemoryLifecycleMediator lifecycleMediator;
|
||||
private final MemoryProperties memoryProperties;
|
||||
|
||||
/** 运行时 Agent 实例缓存(agentId -> BaseAgent) */
|
||||
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
|
||||
@ -93,13 +100,16 @@ public class AgentService {
|
||||
public String chat(Long agentId, String message, String conversationId) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chat(message, conversationId);
|
||||
return withLifecycleSync(agentId, message, conversationId,
|
||||
() -> agent.chat(message, conversationId));
|
||||
}
|
||||
|
||||
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chatStream(message, conversationId);
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
() -> agent.chatStream(message, conversationId),
|
||||
chunk -> chunk);
|
||||
}
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) {
|
||||
@ -130,21 +140,26 @@ public class AgentService {
|
||||
}
|
||||
|
||||
if (agent instanceof StructuredStreamCapable capable) {
|
||||
return capable.chatStructuredStream(message, conversationId,
|
||||
requesterId != null ? requesterId : "")
|
||||
.doFinally(signal -> ThinkingLevelHolder.clear());
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
() -> capable.chatStructuredStream(message, conversationId,
|
||||
requesterId != null ? requesterId : "")
|
||||
.doFinally(signal -> ThinkingLevelHolder.clear()),
|
||||
StreamDelta::content);
|
||||
}
|
||||
|
||||
// 降级:不支持结构化流的 Agent,包装为纯内容流
|
||||
ThinkingLevelHolder.clear();
|
||||
return agent.chatStream(message, conversationId)
|
||||
.map(chunk -> new StreamDelta(chunk, null));
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
() -> agent.chatStream(message, conversationId)
|
||||
.map(chunk -> new StreamDelta(chunk, null)),
|
||||
StreamDelta::content);
|
||||
}
|
||||
|
||||
public String execute(Long agentId, String goal, String conversationId) {
|
||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.execute(goal, conversationId);
|
||||
return withLifecycleSync(agentId, goal, conversationId,
|
||||
() -> agent.execute(goal, conversationId));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -160,7 +175,8 @@ public class AgentService {
|
||||
String toolCallPayload) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chatWithReplay(userMessage, conversationId, toolCallPayload);
|
||||
return withLifecycleSync(agentId, userMessage, conversationId,
|
||||
() -> agent.chatWithReplay(userMessage, conversationId, toolCallPayload));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -175,8 +191,10 @@ public class AgentService {
|
||||
String toolCallPayload, String requesterId) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload,
|
||||
requesterId != null ? requesterId : "");
|
||||
return withLifecycleFlux(agentId, userMessage, conversationId,
|
||||
() -> agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload,
|
||||
requesterId != null ? requesterId : ""),
|
||||
StreamDelta::content);
|
||||
}
|
||||
|
||||
public AgentState getAgentState(Long agentId) {
|
||||
@ -208,6 +226,47 @@ public class AgentService {
|
||||
log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)");
|
||||
}
|
||||
|
||||
// ==================== Lifecycle helpers ====================
|
||||
|
||||
/**
|
||||
* Wraps a synchronous agent call with lifecycle mediator hooks.
|
||||
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
|
||||
*/
|
||||
private String withLifecycleSync(Long agentId, String message, String conversationId,
|
||||
Supplier<String> plainInvoke) {
|
||||
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||
return plainInvoke.get();
|
||||
}
|
||||
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
|
||||
lifecycleMediator.beforeLlmCall(ctx);
|
||||
String result = plainInvoke.get();
|
||||
lifecycleMediator.afterLlmCall(ctx, result != null ? result : "");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a streaming agent call with lifecycle mediator hooks.
|
||||
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
|
||||
*/
|
||||
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
|
||||
Supplier<Flux<T>> plainInvoke,
|
||||
Function<T, String> contentExtractor) {
|
||||
if (!memoryProperties.isLifecycleMediatorEnabled()) {
|
||||
return plainInvoke.get();
|
||||
}
|
||||
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
|
||||
lifecycleMediator.beforeLlmCall(ctx);
|
||||
StringBuilder reply = new StringBuilder();
|
||||
return plainInvoke.get()
|
||||
.doOnNext(item -> {
|
||||
String text = contentExtractor.apply(item);
|
||||
if (text != null) {
|
||||
reply.append(text);
|
||||
}
|
||||
})
|
||||
.doFinally(signal -> lifecycleMediator.afterLlmCall(ctx, reply.toString()));
|
||||
}
|
||||
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
private BaseAgent getOrBuildAgent(Long agentId) {
|
||||
|
||||
@ -80,4 +80,63 @@ public class MemoryProperties {
|
||||
|
||||
/** 禁用的 MemoryProvider ID 集合(例如 "structured", "session_search") */
|
||||
private Set<String> disabledProviders = new HashSet<>();
|
||||
|
||||
// ==================== Dream v2 Feature Flags ====================
|
||||
|
||||
// --- Phase 1: Lifecycle mediator wiring ---
|
||||
|
||||
/** Enable MemoryLifecycleMediator prefetch/sync/onSessionEnd chain; off = Phase 0 behavior only */
|
||||
private boolean lifecycleMediatorEnabled = false;
|
||||
|
||||
/** Dream v2 configuration */
|
||||
private DreamProperties dream = new DreamProperties();
|
||||
|
||||
// --- Phase 2: SOUL auto-evolution and provider decorators ---
|
||||
|
||||
/** SOUL.md auto-evolution trigger interval (0 = off) */
|
||||
private int soulUpdateInterval = 0;
|
||||
|
||||
/** Provider decorator retry attempts (1 = no retry) */
|
||||
private int providerRetryAttempts = 1;
|
||||
|
||||
/** Enable provider metrics collection */
|
||||
private boolean providerMetricsEnabled = false;
|
||||
|
||||
// --- Phase 3: Fact projection ---
|
||||
|
||||
/** Fact projection configuration */
|
||||
private FactProperties fact = new FactProperties();
|
||||
|
||||
@Data
|
||||
public static class DreamProperties {
|
||||
/** Enable focused dream mode endpoint */
|
||||
private boolean focusedEnabled = false;
|
||||
|
||||
/** Enable monthly dream archive rotation */
|
||||
private boolean archiveEnabled = false;
|
||||
|
||||
/** Days to keep in DREAMS.md before archiving */
|
||||
private int archiveKeepDays = 30;
|
||||
|
||||
/** Maximum candidates per dream consolidation run */
|
||||
private int maxCandidatesPerDream = 100;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class FactProperties {
|
||||
/** Enable fact projection rebuild */
|
||||
private boolean projectionEnabled = false;
|
||||
|
||||
/** Fact projection rebuild cron expression */
|
||||
private String projectionRebuildCron = "0 */30 * * * ?";
|
||||
|
||||
/** Enable LLM-based entity extraction (false = pattern-only) */
|
||||
private boolean llmExtractionEnabled = false;
|
||||
|
||||
/** Enable contradiction detection in dream consolidation */
|
||||
private boolean contradictionCheckEnabled = false;
|
||||
|
||||
/** Trust score half-life in days for time decay */
|
||||
private int trustHalfLifeDays = 60;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package vip.mate.memory.lifecycle;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.event.ConversationCompletedEvent;
|
||||
|
||||
/**
|
||||
* Dispatches ConversationCompletedEvent to MemoryManager.onSessionEnd unconditionally.
|
||||
*
|
||||
* <p>Contract: every successfully-persisted conversation end must reach all memory
|
||||
* providers, regardless of whether summarization / nudge preconditions held.
|
||||
*
|
||||
* <p>This is a separate listener from PostConversationMemoryListener on purpose —
|
||||
* that one has four early returns tied to summarize/nudge heuristics. Those are fine
|
||||
* for summarize/nudge business logic, but none of them are appropriate gates for
|
||||
* provider-level session-end signals (rfc-037 §3.7, decision D10).
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MemoryLifecycleEventListener {
|
||||
|
||||
private final MemoryLifecycleMediator mediator;
|
||||
private final MemoryProperties props;
|
||||
|
||||
@Async
|
||||
@EventListener
|
||||
public void onConversationCompleted(ConversationCompletedEvent event) {
|
||||
if (!props.isLifecycleMediatorEnabled()) return;
|
||||
try {
|
||||
mediator.onSessionEnd(event.agentId(), event.conversationId());
|
||||
} catch (Exception e) {
|
||||
log.debug("[Memory] onSessionEnd dispatch failed (non-fatal): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package vip.mate.memory.lifecycle;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.memory.spi.MemoryManager;
|
||||
|
||||
/**
|
||||
* Mediator between the Agent entry-layer (AgentService) and MemoryManager.
|
||||
* Agent code only calls this class; it hides the details of when/how
|
||||
* providers are invoked across a turn's lifecycle.
|
||||
*
|
||||
* <p>Non-goals:
|
||||
* <ul>
|
||||
* <li>Does NOT call MemoryRecallTracker.trackRecalls — AgentService already
|
||||
* owns that call; duplicating here would double recall_count / daily_count
|
||||
* and pollute Dream scoring (rfc-037 F4).</li>
|
||||
* <li>Does NOT prefetch next-turn recall keyed on current-turn query —
|
||||
* query-conditioned providers cannot reuse stale queries (rfc-037 F2).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Thread-safety: all public methods are reentrant; per-turn state lives
|
||||
* in {@link TurnContext}.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MemoryLifecycleMediator {
|
||||
|
||||
private final MemoryManager memoryManager;
|
||||
private final ApplicationEventPublisher events;
|
||||
|
||||
/**
|
||||
* Called BEFORE the LLM is invoked for a turn.
|
||||
* Returns the memory-context block to inject, or "" if none.
|
||||
*
|
||||
* <p>Latency contract: synchronous. BuiltinMemoryProvider returns "" so the
|
||||
* only cost today is iteration overhead (<5ms).
|
||||
*/
|
||||
public String beforeLlmCall(TurnContext ctx) {
|
||||
try {
|
||||
String context = memoryManager.prefetchAll(ctx.agentId(), ctx.userQuery());
|
||||
events.publishEvent(new TurnStartedEvent(ctx));
|
||||
return context;
|
||||
} catch (Exception e) {
|
||||
log.debug("[Memory] beforeLlmCall failed (non-fatal): {}", e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called AFTER the LLM finishes a turn successfully.
|
||||
* Non-blocking: MemoryManager.syncAll dispatches to provider.syncTurn(),
|
||||
* each provider is responsible for being async internally.
|
||||
*/
|
||||
public void afterLlmCall(TurnContext ctx, String assistantReply) {
|
||||
try {
|
||||
memoryManager.syncAll(ctx.agentId(), ctx.conversationId(),
|
||||
ctx.userQuery(), assistantReply);
|
||||
events.publishEvent(new TurnCompletedEvent(ctx, assistantReply));
|
||||
} catch (Exception e) {
|
||||
log.debug("[Memory] afterLlmCall failed (non-fatal): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a conversation ends (from MemoryLifecycleEventListener).
|
||||
*/
|
||||
public void onSessionEnd(Long agentId, String conversationId) {
|
||||
try {
|
||||
memoryManager.onSessionEnd(agentId, conversationId);
|
||||
} catch (Exception e) {
|
||||
log.debug("[Memory] onSessionEnd dispatch failed (non-fatal): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package vip.mate.memory.lifecycle;
|
||||
|
||||
/**
|
||||
* Published after syncAll completes for a turn.
|
||||
*
|
||||
* @param context the turn context
|
||||
* @param assistantReply the LLM response text
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public record TurnCompletedEvent(TurnContext context, String assistantReply) {}
|
||||
@ -0,0 +1,19 @@
|
||||
package vip.mate.memory.lifecycle;
|
||||
|
||||
/**
|
||||
* Minimal turn-scoped context; built once per turn at AgentService level.
|
||||
*
|
||||
* @param agentId the agent ID
|
||||
* @param conversationId the conversation ID
|
||||
* @param sessionId session ID (may equal conversationId in Phase 1)
|
||||
* @param turnNumber turn sequence number within the conversation
|
||||
* @param userQuery the current user message
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public record TurnContext(
|
||||
Long agentId,
|
||||
String conversationId,
|
||||
String sessionId,
|
||||
int turnNumber,
|
||||
String userQuery
|
||||
) {}
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.memory.lifecycle;
|
||||
|
||||
/**
|
||||
* Published after prefetchAll completes, before the LLM call.
|
||||
*
|
||||
* @param context the turn context
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public record TurnStartedEvent(TurnContext context) {}
|
||||
@ -95,4 +95,18 @@ public interface MemoryProvider {
|
||||
default String onPreCompress(Long agentId, List<?> messages) {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification that a memory write occurred. Called after canonical memory
|
||||
* files (structured/*.md, MEMORY.md) are updated.
|
||||
*
|
||||
* <p>Phase 1: no subscribers. Phase 2: SOUL auto-evolution hook.
|
||||
*
|
||||
* @param agentId the agent ID
|
||||
* @param target which file was written (e.g. "MEMORY.md", "structured/user_pref.md")
|
||||
* @param action what happened ("append", "update", "consolidate")
|
||||
* @param content the written content
|
||||
*/
|
||||
default void onMemoryWrite(Long agentId, String target, String action, String content) {
|
||||
}
|
||||
}
|
||||
|
||||
@ -198,3 +198,23 @@ mate:
|
||||
upload-dir: ./data/wiki-uploads # 上传文件存储目录
|
||||
max-scan-files: 500 # 目录扫描最大文件数
|
||||
max-scan-file-size: 52428800 # 扫描时跳过大于此大小的文件(字节,默认 50MB)
|
||||
# Dream v2 feature flags (all default off; enable per phase after staging validation)
|
||||
memory:
|
||||
# Phase 1: lifecycle mediator wiring
|
||||
lifecycle-mediator-enabled: false
|
||||
dream:
|
||||
focused-enabled: false
|
||||
archive-enabled: false
|
||||
archive-keep-days: 30
|
||||
max-candidates-per-dream: 100
|
||||
# Phase 2: SOUL auto-evolution and provider decorators (enable after Phase 1 GA)
|
||||
soul-update-interval: 0
|
||||
provider-retry-attempts: 1
|
||||
provider-metrics-enabled: false
|
||||
# Phase 3: fact projection (enable after Phase 2 GA)
|
||||
fact:
|
||||
projection-enabled: false
|
||||
projection-rebuild-cron: "0 */30 * * * ?"
|
||||
llm-extraction-enabled: false
|
||||
contradiction-check-enabled: false
|
||||
trust-half-life-days: 60
|
||||
|
||||
@ -0,0 +1,161 @@
|
||||
package vip.mate.memory.lifecycle;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.event.ConversationCompletedEvent;
|
||||
import vip.mate.memory.spi.MemoryManager;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* A.8 — Flag guard test: verifies that lifecycleMediatorEnabled=false
|
||||
* means zero calls to prefetchAll / syncAll / onSessionEnd, and that
|
||||
* enabling the flag activates all three.
|
||||
*
|
||||
* <p>Covers both AgentService helper paths (via Mediator) and
|
||||
* MemoryLifecycleEventListener (via onConversationCompleted).
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LifecycleFlagGuardTest {
|
||||
|
||||
@Mock private MemoryManager memoryManager;
|
||||
@Mock private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
private MemoryProperties props;
|
||||
private MemoryLifecycleMediator mediator;
|
||||
private MemoryLifecycleEventListener listener;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
props = new MemoryProperties();
|
||||
mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher);
|
||||
listener = new MemoryLifecycleEventListener(mediator, props);
|
||||
}
|
||||
|
||||
// ==================== Flag OFF ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Flag OFF: MemoryLifecycleEventListener.onConversationCompleted is a no-op")
|
||||
void flagOff_listenerNoOp() {
|
||||
props.setLifecycleMediatorEnabled(false);
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
listener.onConversationCompleted(
|
||||
new ConversationCompletedEvent(1L, "conv-" + i, "hello", "reply", 5, "web"));
|
||||
}
|
||||
|
||||
verify(memoryManager, never()).prefetchAll(any(), any());
|
||||
verify(memoryManager, never()).syncAll(any(), any(), any(), any());
|
||||
verify(memoryManager, never()).onSessionEnd(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Flag OFF: Mediator methods still work (called by AgentService helpers only when flag is on)")
|
||||
void flagOff_mediatorDirectCallsStillWork() {
|
||||
// Mediator itself has no flag check — that's AgentService's job.
|
||||
// But MemoryLifecycleEventListener guards onSessionEnd.
|
||||
props.setLifecycleMediatorEnabled(false);
|
||||
|
||||
when(memoryManager.prefetchAll(eq(1L), eq("q"))).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");
|
||||
}
|
||||
|
||||
// ==================== Flag ON ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Flag ON: beforeLlmCall invokes prefetchAll")
|
||||
void flagOn_prefetchAll() {
|
||||
props.setLifecycleMediatorEnabled(true);
|
||||
when(memoryManager.prefetchAll(eq(1L), eq("hello"))).thenReturn("");
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", i, "hello"));
|
||||
}
|
||||
|
||||
verify(memoryManager, times(10)).prefetchAll(1L, "hello");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Flag ON: afterLlmCall invokes syncAll")
|
||||
void flagOn_syncAll() {
|
||||
props.setLifecycleMediatorEnabled(true);
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
mediator.afterLlmCall(new TurnContext(1L, "c1", "s1", i, "hello"), "reply-" + i);
|
||||
}
|
||||
|
||||
verify(memoryManager, times(10)).syncAll(eq(1L), eq("c1"), eq("hello"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Flag ON: onConversationCompleted invokes onSessionEnd")
|
||||
void flagOn_onSessionEnd() {
|
||||
props.setLifecycleMediatorEnabled(true);
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
listener.onConversationCompleted(
|
||||
new ConversationCompletedEvent(1L, "conv-" + i, "hello", "reply", 5, "web"));
|
||||
}
|
||||
|
||||
verify(memoryManager, times(10)).onSessionEnd(eq(1L), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Flag ON: cron conversations also trigger onSessionEnd")
|
||||
void flagOn_cronConversation() {
|
||||
props.setLifecycleMediatorEnabled(true);
|
||||
|
||||
listener.onConversationCompleted(
|
||||
new ConversationCompletedEvent(1L, "cron-conv", "task", "done", 2, "cron"));
|
||||
|
||||
verify(memoryManager, times(1)).onSessionEnd(1L, "cron-conv");
|
||||
}
|
||||
|
||||
// ==================== Provider exception degradation ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Provider exception in prefetchAll degrades gracefully (returns empty)")
|
||||
void prefetchException_graceful() {
|
||||
when(memoryManager.prefetchAll(any(), any())).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q"));
|
||||
|
||||
// Should return empty string, not throw
|
||||
assert result.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Provider exception in syncAll degrades gracefully (no throw)")
|
||||
void syncException_graceful() {
|
||||
org.mockito.Mockito.doThrow(new RuntimeException("boom"))
|
||||
.when(memoryManager).syncAll(any(), any(), any(), any());
|
||||
|
||||
// Should not throw
|
||||
mediator.afterLlmCall(new TurnContext(1L, "c1", "s1", 1, "q"), "reply");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Provider exception in onSessionEnd degrades gracefully (no throw)")
|
||||
void sessionEndException_graceful() {
|
||||
org.mockito.Mockito.doThrow(new RuntimeException("boom"))
|
||||
.when(memoryManager).onSessionEnd(any(), any());
|
||||
|
||||
// Should not throw
|
||||
mediator.onSessionEnd(1L, "c1");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,141 @@
|
||||
package vip.mate.memory.lifecycle;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import vip.mate.agent.AgentGraphBuilder;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.BaseAgent;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.service.MemoryRecallTracker;
|
||||
import vip.mate.memory.spi.MemoryManager;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* A.10 — F4 regression test: recall_count / daily_count must remain
|
||||
* identical whether lifecycleMediatorEnabled is on or off.
|
||||
*
|
||||
* <p>Verifies that MemoryLifecycleMediator never calls trackRecalls,
|
||||
* and AgentService calls trackRecalls exactly once per chat entry
|
||||
* regardless of the flag state.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LifecycleRecallCountIT {
|
||||
|
||||
@Mock private AgentMapper agentMapper;
|
||||
@Mock private AgentGraphBuilder agentGraphBuilder;
|
||||
@Mock private MemoryRecallTracker memoryRecallTracker;
|
||||
@Mock private MemoryManager memoryManager;
|
||||
@Mock private ApplicationEventPublisher eventPublisher;
|
||||
@Mock private BaseAgent mockAgent;
|
||||
|
||||
private MemoryProperties props;
|
||||
private AgentService agentService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
props = new MemoryProperties();
|
||||
MemoryLifecycleMediator mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher);
|
||||
agentService = new AgentService(agentMapper, agentGraphBuilder,
|
||||
memoryRecallTracker, mediator, props);
|
||||
|
||||
// Stub agent resolution (lenient for structural-only tests)
|
||||
AgentEntity entity = new AgentEntity();
|
||||
entity.setId(1L);
|
||||
entity.setEnabled(true);
|
||||
lenient().when(agentMapper.selectById(1L)).thenReturn(entity);
|
||||
lenient().when(agentGraphBuilder.build(any(AgentEntity.class))).thenReturn(mockAgent);
|
||||
lenient().when(mockAgent.chat(any(), any())).thenReturn("reply");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("F4 regression: flag OFF — trackRecalls called once per chat, mediator is silent")
|
||||
void flagOff_trackRecallsOncePerChat() {
|
||||
props.setLifecycleMediatorEnabled(false);
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
agentService.chat(1L, "msg-" + i, "conv-1");
|
||||
}
|
||||
|
||||
// trackRecalls: exactly 10 times (once per chat call)
|
||||
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()).syncAll(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@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("");
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
agentService.chat(1L, "msg-" + i, "conv-1");
|
||||
}
|
||||
|
||||
// trackRecalls: still exactly 10 times — NOT 20 (D4: mediator does not call trackRecalls)
|
||||
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any());
|
||||
|
||||
// Mediator IS invoked
|
||||
verify(memoryManager, times(10)).prefetchAll(eq(1L), any());
|
||||
verify(memoryManager, times(10)).syncAll(eq(1L), eq("conv-1"), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("F4 regression: flag toggle does not change trackRecalls count")
|
||||
void flagToggle_sameTrackRecallsCount() {
|
||||
// 5 rounds with flag OFF
|
||||
props.setLifecycleMediatorEnabled(false);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
agentService.chat(1L, "off-" + i, "conv-1");
|
||||
}
|
||||
|
||||
// 5 rounds with flag ON
|
||||
props.setLifecycleMediatorEnabled(true);
|
||||
when(memoryManager.prefetchAll(any(), any())).thenReturn("");
|
||||
for (int i = 0; i < 5; i++) {
|
||||
agentService.chat(1L, "on-" + i, "conv-1");
|
||||
}
|
||||
|
||||
// Total: 10 trackRecalls calls regardless of flag state
|
||||
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any());
|
||||
|
||||
// Mediator only called for the ON rounds
|
||||
verify(memoryManager, times(5)).prefetchAll(eq(1L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Mediator source code does not reference trackRecalls (structural guard)")
|
||||
void mediator_noTrackRecallsReference() throws Exception {
|
||||
// Structural assertion: MemoryLifecycleMediator has no field or method
|
||||
// that references MemoryRecallTracker
|
||||
var mediatorClass = MemoryLifecycleMediator.class;
|
||||
for (var field : mediatorClass.getDeclaredFields()) {
|
||||
if (field.getType().getSimpleName().contains("RecallTracker")) {
|
||||
throw new AssertionError("Mediator must not depend on MemoryRecallTracker (D4)");
|
||||
}
|
||||
}
|
||||
// Also verify via declared constructor params
|
||||
var ctorParams = mediatorClass.getDeclaredConstructors()[0].getParameterTypes();
|
||||
for (var param : ctorParams) {
|
||||
if (param.getSimpleName().contains("RecallTracker")) {
|
||||
throw new AssertionError("Mediator constructor must not accept MemoryRecallTracker (D4)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
package vip.mate.memory.lifecycle;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import vip.mate.memory.spi.MemoryManager;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* A.9 — Unit tests for MemoryLifecycleMediator covering:
|
||||
* normal path, provider exception degradation, and onSessionEnd for cron conversations.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MemoryLifecycleMediatorTest {
|
||||
|
||||
@Mock private MemoryManager memoryManager;
|
||||
@Mock private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
private MemoryLifecycleMediator mediator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher);
|
||||
}
|
||||
|
||||
// ==================== Normal path ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("beforeLlmCall returns prefetchAll result and publishes TurnStartedEvent")
|
||||
void beforeLlmCall_normalPath() {
|
||||
when(memoryManager.prefetchAll(eq(1L), eq("hello")))
|
||||
.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");
|
||||
|
||||
ArgumentCaptor<Object> eventCaptor = ArgumentCaptor.forClass(Object.class);
|
||||
verify(eventPublisher).publishEvent(eventCaptor.capture());
|
||||
assertTrue(eventCaptor.getValue() instanceof TurnStartedEvent);
|
||||
assertEquals(ctx, ((TurnStartedEvent) eventCaptor.getValue()).context());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("beforeLlmCall returns empty string when prefetchAll returns empty")
|
||||
void beforeLlmCall_emptyPrefetch() {
|
||||
when(memoryManager.prefetchAll(any(), any())).thenReturn("");
|
||||
|
||||
String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q"));
|
||||
|
||||
assertEquals("", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("afterLlmCall calls syncAll and publishes TurnCompletedEvent")
|
||||
void afterLlmCall_normalPath() {
|
||||
TurnContext ctx = new TurnContext(1L, "c1", "s1", 1, "hello");
|
||||
mediator.afterLlmCall(ctx, "reply text");
|
||||
|
||||
verify(memoryManager).syncAll(1L, "c1", "hello", "reply text");
|
||||
|
||||
ArgumentCaptor<Object> eventCaptor = ArgumentCaptor.forClass(Object.class);
|
||||
verify(eventPublisher).publishEvent(eventCaptor.capture());
|
||||
assertTrue(eventCaptor.getValue() instanceof TurnCompletedEvent);
|
||||
TurnCompletedEvent event = (TurnCompletedEvent) eventCaptor.getValue();
|
||||
assertEquals(ctx, event.context());
|
||||
assertEquals("reply text", event.assistantReply());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("onSessionEnd delegates to memoryManager.onSessionEnd")
|
||||
void onSessionEnd_normalPath() {
|
||||
mediator.onSessionEnd(1L, "conv-123");
|
||||
|
||||
verify(memoryManager).onSessionEnd(1L, "conv-123");
|
||||
}
|
||||
|
||||
// ==================== Provider exception degradation ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("beforeLlmCall degrades to empty string when prefetchAll throws")
|
||||
void beforeLlmCall_exceptionDegrades() {
|
||||
when(memoryManager.prefetchAll(any(), any()))
|
||||
.thenThrow(new RuntimeException("provider down"));
|
||||
|
||||
String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q"));
|
||||
|
||||
assertEquals("", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("afterLlmCall swallows syncAll exceptions")
|
||||
void afterLlmCall_exceptionSwallowed() {
|
||||
doThrow(new RuntimeException("sync failed"))
|
||||
.when(memoryManager).syncAll(any(), any(), any(), any());
|
||||
|
||||
// Should not throw
|
||||
mediator.afterLlmCall(new TurnContext(1L, "c1", "s1", 1, "q"), "reply");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("onSessionEnd swallows exceptions")
|
||||
void onSessionEnd_exceptionSwallowed() {
|
||||
doThrow(new RuntimeException("session end failed"))
|
||||
.when(memoryManager).onSessionEnd(any(), any());
|
||||
|
||||
// Should not throw
|
||||
mediator.onSessionEnd(1L, "c1");
|
||||
}
|
||||
|
||||
// ==================== Cron conversations ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("onSessionEnd works the same for cron-triggered conversations")
|
||||
void onSessionEnd_cronConversation() {
|
||||
// onSessionEnd has no special handling for trigger source;
|
||||
// that distinction only matters in PostConversationMemoryListener.
|
||||
// The mediator processes all conversations equally.
|
||||
mediator.onSessionEnd(42L, "cron-conv-001");
|
||||
|
||||
verify(memoryManager, times(1)).onSessionEnd(42L, "cron-conv-001");
|
||||
}
|
||||
|
||||
// ==================== Reentrant / multi-turn ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Multiple sequential turns do not interfere (Mediator is stateless)")
|
||||
void multipleTurns_noInterference() {
|
||||
when(memoryManager.prefetchAll(any(), any())).thenReturn("");
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
TurnContext ctx = new TurnContext(1L, "c1", "s1", i, "msg-" + i);
|
||||
mediator.beforeLlmCall(ctx);
|
||||
mediator.afterLlmCall(ctx, "reply-" + i);
|
||||
}
|
||||
|
||||
verify(memoryManager, times(5)).prefetchAll(eq(1L), any());
|
||||
verify(memoryManager, times(5)).syncAll(eq(1L), eq("c1"), any(), any());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user