feat(memory): bound always-on memory growth with injection budget, consolidation, and file ceilings

Always-on memory (structured user/feedback blocks, PROFILE.md, MEMORY.md) is injected into every system prompt but only ever grew, inflating per-turn context over time. This adds deterministic size control across all always-on sources:

- Injection budget: cap the always-on structured block by total chars and per-type entry count, keeping the most-recently-updated entries (LRU by Updated date) and disclosing how many were omitted
- Nightly consolidation: a dedicated scheduled pass merges duplicate/stale user & feedback entries via the LLM, preserving each entry's original Updated date; runs per owner bucket (shared + personal) with a per-run cap and a never-grow safety guard
- File ceilings: deterministic backstop truncates PROFILE.md / MEMORY.md at a section boundary when a rewrite overruns its budget
- Manual trigger endpoint for the consolidation maintenance task

All knobs under mate.memory.*; covered by unit tests.
This commit is contained in:
matevip 2026-06-17 11:04:19 +08:00
parent 1affbd7b82
commit fe68f22aa8
16 changed files with 957 additions and 17 deletions

View File

@ -81,6 +81,66 @@ public class MemoryProperties {
/** 禁用的 MemoryProvider ID 集合(例如 "structured", "session_search" */
private Set<String> disabledProviders = new HashSet<>();
// ==================== Always-on injection budget ====================
/**
* Character budget for the always-on structured memory block injected into
* every system prompt (user + feedback entries). When exceeded, the
* most-recently-updated entries are kept and older ones are omitted, so
* accumulated memory cannot grow the per-turn context without bound.
* 0 = unlimited (legacy behavior).
*/
private int systemBlockMaxChars = 4000;
/**
* Hard cap on the number of entries injected per structured type in the
* always-on block. Keeps the newest entries per type even when the global
* character budget would otherwise admit more from a single type.
* 0 = unlimited.
*/
private int systemBlockMaxEntriesPerType = 40;
/**
* Enable the nightly consolidation pass over always-on structured memory
* (user/feedback): an LLM merges near-duplicate and stale entries so the
* files shrink on disk rather than only being trimmed at injection time.
*/
private boolean structuredConsolidationEnabled = true;
/**
* Minimum entry count before a structured type is consolidated. Below this
* the file is already small, so the LLM call is skipped.
*/
private int structuredConsolidationMinEntries = 8;
/**
* Cron expression for the structured-memory consolidation maintenance task.
* Independent of {@code dreamingCron} so the two passes can be scheduled
* (and gated) separately. Default: 3:30 AM daily, after nightly emergence.
*/
private String structuredConsolidationCron = "0 30 3 * * ?";
/**
* Maximum number of owner buckets (shared + personal) consolidated per agent
* per run. Bounds LLM cost on agents with many per-owner memory buckets;
* remaining owners are picked up on subsequent runs. 0 = unlimited.
*/
private int structuredConsolidationMaxOwnersPerRun = 50;
/**
* Deterministic character ceiling for PROFILE.md, the always-on user-profile
* file. The summarization pass rewrites it and is asked to stay concise; this
* is the hard backstop that truncates at a section boundary if it overruns.
* 0 = unlimited.
*/
private int profileMaxChars = 4000;
/**
* Deterministic character ceiling for MEMORY.md, the always-on long-term
* memory file rewritten by summarization and emergence. 0 = unlimited.
*/
private int memoryMdMaxChars = 8000;
// ==================== Dream v2 Feature Flags ====================
// --- Phase 1: Lifecycle mediator wiring ---

View File

@ -37,6 +37,30 @@ public class MemoryController {
private final MemoryProperties memoryProperties;
private final DreamingScheduler dreamingScheduler;
private final WorkspaceFileService workspaceFileService;
private final StructuredMemoryConsolidationService structuredConsolidationService;
@Operation(summary = "手动触发 always-on 结构化记忆整合user/feedback合并去重过时条目")
@PostMapping("/{agentId}/structured-consolidation")
@RequireWorkspaceRole("member")
public R<Map<String, Object>> triggerStructuredConsolidation(@PathVariable Long agentId) {
try {
StructuredMemoryConsolidationService.ConsolidationStats s =
structuredConsolidationService.consolidateAgent(agentId);
Map<String, Object> out = new LinkedHashMap<>();
out.put("ownersConsolidated", s.ownersConsolidated);
out.put("updated", s.updated);
out.put("skippedSmall", s.skippedSmall);
out.put("skippedOverCap", s.skippedOverCap);
out.put("failed", s.failed);
out.put("entriesBefore", s.entriesBefore);
out.put("entriesAfter", s.entriesAfter);
return R.ok(out);
} catch (Exception e) {
log.error("[Memory] Manual structured consolidation failed for agent={}: {}",
agentId, e.getMessage(), e);
return R.fail("结构化记忆整合失败: " + e.getMessage());
}
}
@Operation(summary = "手动触发记忆整合daily notes → MEMORY.mdNIGHTLY 模式)")
@PostMapping("/{agentId}/emergence")

View File

@ -0,0 +1,72 @@
package vip.mate.memory.scheduler;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.memory.MemoryProperties;
import vip.mate.memory.service.StructuredMemoryConsolidationService;
import vip.mate.memory.service.StructuredMemoryConsolidationService.ConsolidationStats;
import java.time.LocalDateTime;
/**
* Scheduled maintenance for always-on structured memory.
* <p>
* Runs the consolidation pass that merges duplicate / stale user & feedback
* entries so the always-on block shrinks at the storage level. Kept separate from
* {@link DreamingScheduler} so it has its own cron and enable flag disabling
* nightly dreaming must not silently disable structured consolidation, and vice
* versa.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class StructuredMemoryMaintenanceScheduler {
private final AgentService agentService;
private final StructuredMemoryConsolidationService consolidationService;
private final MemoryProperties properties;
/** Last run time, for the status API. */
@Getter
private volatile LocalDateTime lastRunTime;
@Scheduled(cron = "${mate.memory.structured-consolidation-cron:0 30 3 * * ?}")
public void runConsolidation() {
if (!properties.isStructuredConsolidationEnabled()) {
log.debug("[StructuredConsolidation] Disabled, skipping");
return;
}
log.info("[StructuredConsolidation] Starting maintenance cycle");
ConsolidationStats total = new ConsolidationStats();
int agents = 0;
for (AgentEntity agent : agentService.listAgents()) {
if (!Boolean.TRUE.equals(agent.getEnabled())) {
continue;
}
agents++;
try {
ConsolidationStats agentStats = consolidationService.consolidateAgent(agent.getId());
total.add(agentStats);
} catch (Exception e) {
log.warn("[StructuredConsolidation] Failed for agent={} ({}): {}",
agent.getId(), agent.getName(), e.getMessage());
}
}
lastRunTime = LocalDateTime.now();
log.info("[StructuredConsolidation] Cycle done: agents={}, buckets={}, updated={}, "
+ "skipped(small)={}, skipped(cap)={}, failed={}, entries {}->{}",
agents, total.ownersConsolidated, total.updated,
total.skippedSmall, total.skippedOverCap, total.failed,
total.entriesBefore, total.entriesAfter);
}
}

View File

@ -0,0 +1,42 @@
package vip.mate.memory.service;
/**
* Deterministic character-budget backstop for always-on memory files
* (PROFILE.md / MEMORY.md) that are injected into every system prompt.
* <p>
* These files are rewritten wholesale by the summarization and emergence passes,
* which are instructed to stay concise but have no hard ceiling so on their own
* they can grow the per-turn context without bound. This enforces a deterministic
* cap: when content exceeds the budget it is truncated at a Markdown section
* boundary (keeping the head, where the core/principle sections live) and a marker
* is appended. The LLM rewrite remains the primary, content-aware compressor; this
* is the last-resort guarantee that the file stays bounded.
*
* @author MateClaw Team
*/
final class AlwaysOnFileBudget {
/** Appended when content is truncated; user-facing note kept in the file. */
static final String MARKER = "\n\n> ⚠️ 后续内容已截断以控制注入体积。";
private AlwaysOnFileBudget() {
}
/**
* Truncate {@code content} to at most {@code maxChars} characters, cutting at
* the last {@code "## "} section boundary that fits so a section is never split
* mid-way. Returns the input unchanged when it already fits or when budgeting
* is disabled ({@code maxChars <= 0}).
*/
static String enforce(String content, int maxChars) {
if (content == null || maxChars <= 0 || content.length() <= maxChars) {
return content;
}
int limit = Math.max(0, maxChars - MARKER.length());
// Prefer cutting at a section boundary within the budget; "\n## " keeps the
// leading section header intact. Fall back to a hard cut when none fits.
int boundary = content.lastIndexOf("\n## ", limit);
String head = boundary > 0 ? content.substring(0, boundary) : content.substring(0, limit);
return head.stripTrailing() + MARKER;
}
}

View File

@ -159,6 +159,9 @@ public class MemoryEmergenceService {
return buildSkippedReport(agentId, mode, topic, triggerSource, startedAt, "empty memory_content");
}
// Deterministic backstop so the always-on MEMORY.md stays bounded even
// if the LLM rewrite ignores the "keep concise" instruction.
newContent = AlwaysOnFileBudget.enforce(newContent, properties.getMemoryMdMaxChars());
workspaceFileService.saveFile(agentId, "MEMORY.md", newContent);
eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "consolidate", newContent));
String llmReason = root.path("reason").asText("");

View File

@ -328,6 +328,7 @@ public class MemorySummarizationService {
* (TEAM) file when there is no real owner (cron / system).
*/
private void saveMemory(Long agentId, String filename, String content, String ownerKey) {
content = capAlwaysOnFile(filename, content);
if (isPersonal(ownerKey)) {
workspaceFileService.saveMemoryFile(agentId, filename, content, ownerKey);
} else {
@ -335,6 +336,21 @@ public class MemorySummarizationService {
}
}
/**
* Enforce the deterministic size ceiling on the always-on profile/memory files
* so they cannot grow the per-turn system prompt without bound. Daily notes and
* other files (only recalled on demand) are left untouched.
*/
private String capAlwaysOnFile(String filename, String content) {
if ("PROFILE.md".equals(filename)) {
return AlwaysOnFileBudget.enforce(content, properties.getProfileMaxChars());
}
if ("MEMORY.md".equals(filename)) {
return AlwaysOnFileBudget.enforce(content, properties.getMemoryMdMaxChars());
}
return 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()

View File

@ -0,0 +1,184 @@
package vip.mate.memory.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.stereotype.Service;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.memory.MemoryProperties;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
/**
* Nightly consolidation of always-on structured memory (user / feedback).
* <p>
* These typed files are injected into every system prompt and only ever grow:
* nudge and post-conversation summarization append entries, key-exact dedup lets
* paraphrased keys through, and no pass ever merges them. Over time the always-on
* block inflates per-turn context. This service periodically rewrites each
* always-on type file into a smaller, deduplicated, non-stale set via the LLM, so
* accumulated memory shrinks at the storage level rather than only being trimmed
* at injection time.
* <p>
* Consolidation runs per bucket: the shared (TEAM/GLOBAL) file plus every personal
* owner's file, because most growth accumulates in per-owner buckets that the
* always-on prefetch injects each turn. The number of buckets processed per agent
* per run is capped to bound LLM cost.
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StructuredMemoryConsolidationService {
private final StructuredMemoryService structuredMemoryService;
private final ModelConfigService modelConfigService;
private final AgentGraphBuilder agentGraphBuilder;
private final MemoryProperties properties;
/** Typed LLM output for a single consolidation call. */
public record ConsolidationResult(boolean shouldUpdate, List<Entry> entries, String reason) {
public record Entry(String key, String content) {}
}
/** Aggregate counters for one consolidation run, for observability. */
public static class ConsolidationStats {
public int ownersConsolidated; // buckets that passed the gate and called the LLM
public int updated; // buckets actually rewritten
public int skippedSmall; // buckets below the min-entries gate
public int skippedOverCap; // buckets deferred to a later run by the per-run cap
public int failed; // buckets whose LLM/parse/write raised
public int entriesBefore;
public int entriesAfter;
public void add(ConsolidationStats o) {
ownersConsolidated += o.ownersConsolidated;
updated += o.updated;
skippedSmall += o.skippedSmall;
skippedOverCap += o.skippedOverCap;
failed += o.failed;
entriesBefore += o.entriesBefore;
entriesAfter += o.entriesAfter;
}
}
/** Consolidate every always-on structured bucket (shared + personal) for an agent. */
public ConsolidationStats consolidateAgent(Long agentId) {
ConsolidationStats stats = new ConsolidationStats();
if (!properties.isStructuredConsolidationEnabled()) {
return stats;
}
int cap = properties.getStructuredConsolidationMaxOwnersPerRun();
int remaining = cap > 0 ? cap : Integer.MAX_VALUE;
for (String type : structuredMemoryService.alwaysOnTypes()) {
for (String ownerKey : structuredMemoryService.consolidatableOwnerKeys(agentId, type)) {
String content = structuredMemoryService.readTypeRaw(agentId, type, ownerKey);
int count = structuredMemoryService.countEntries(content);
if (count < properties.getStructuredConsolidationMinEntries()) {
stats.skippedSmall++;
continue;
}
if (remaining <= 0) {
stats.skippedOverCap++;
continue;
}
remaining--;
stats.ownersConsolidated++;
stats.entriesBefore += count;
try {
int after = consolidateBucket(agentId, type, ownerKey, content, count);
if (after >= 0) {
stats.updated++;
stats.entriesAfter += after;
} else {
stats.entriesAfter += count; // no write bucket unchanged
}
} catch (Exception e) {
stats.failed++;
stats.entriesAfter += count;
log.warn("[StructuredConsolidation] agent={} type={} owner={} failed: {}",
agentId, type, ownerKey, e.getMessage());
}
}
}
return stats;
}
/**
* Consolidate one bucket. Returns the new entry count when the file was
* rewritten, or {@code -1} when nothing was written (LLM declined, produced
* unparseable output, or would not have reduced the entry count).
*/
private int consolidateBucket(Long agentId, String type, String ownerKey, String content, int count) {
BeanOutputConverter<ConsolidationResult> converter = new BeanOutputConverter<>(ConsolidationResult.class);
String systemPrompt = PromptLoader.loadPrompt("memory/consolidate-structured-system");
String userPrompt = PromptLoader.loadPrompt("memory/consolidate-structured-user")
.replace("{type}", type)
.replace("{today}", LocalDate.now().toString())
.replace("{count}", String.valueOf(count))
.replace("{content}", content);
ChatModel chatModel = buildChatModel();
ChatResponse resp = chatModel.call(new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage(userPrompt),
new UserMessage(converter.getFormat()))));
String text = resp.getResult().getOutput().getText();
ConsolidationResult result;
try {
result = converter.convert(text);
} catch (Exception e) {
log.warn("[StructuredConsolidation] Unparseable LLM output agent={} type={} owner={}: {}",
agentId, type, ownerKey, e.getMessage());
return -1;
}
if (result == null || !result.shouldUpdate()
|| result.entries() == null || result.entries().isEmpty()) {
return -1;
}
LinkedHashMap<String, String> consolidated = new LinkedHashMap<>();
for (ConsolidationResult.Entry e : result.entries()) {
if (e == null) continue;
String key = e.key() == null ? "" : e.key().trim();
String value = e.content() == null ? "" : e.content().trim();
if (!key.isEmpty() && !value.isEmpty()) {
consolidated.put(key, value);
}
}
if (consolidated.isEmpty()) {
return -1;
}
// Safety invariant: consolidation must never grow the entry count. A model
// that hallucinates extra entries would otherwise make the bloat worse.
if (consolidated.size() > count) {
log.debug("[StructuredConsolidation] agent={} type={} owner={} produced {} > {} entries; skipping write",
agentId, type, ownerKey, consolidated.size(), count);
return -1;
}
structuredMemoryService.replaceTypeEntries(agentId, type, ownerKey, consolidated, "consolidation");
log.info("[StructuredConsolidation] agent={} type={} owner={} consolidated {} -> {} entries",
agentId, type, ownerKey, count, consolidated.size());
return consolidated.size();
}
private ChatModel buildChatModel() {
ModelConfigEntity defaultModel = modelConfigService.getDefaultModel();
return agentGraphBuilder.buildRuntimeChatModel(defaultModel);
}
}

View File

@ -105,6 +105,7 @@ public class StructuredMemoryService {
private final WorkspaceFileService workspaceFileService;
private final ApplicationEventPublisher eventPublisher;
private final vip.mate.memory.MemoryProperties properties;
/** Per-file lock to prevent concurrent read-modify-write on the same file */
private final ConcurrentHashMap<String, ReentrantLock> fileLocks = new ConcurrentHashMap<>();
@ -240,35 +241,133 @@ public class StructuredMemoryService {
return buildMemoryBlock(agentId, null);
}
/** Reserve left for structural overhead (headers, blank lines) when truncating a single oversized entry. */
private static final int BLOCK_STRUCTURE_RESERVE = 120;
/** Owner-scoped variant of {@link #buildMemoryBlock(Long)}. */
public String buildMemoryBlock(Long agentId, String ownerKey) {
StringBuilder sb = new StringBuilder();
boolean hasContent = false;
int maxChars = Math.max(0, properties.getSystemBlockMaxChars());
int maxPerType = Math.max(0, properties.getSystemBlockMaxEntriesPerType());
// 1. Collect every always-on entry with its update date and a global
// insertion index (file order, types in SYSTEM_PROMPT_TYPES order).
// Truncate any single entry larger than the whole budget so it can
// never blow the cap on its own.
int contentCap = maxChars > 0 ? Math.max(1, maxChars - BLOCK_STRUCTURE_RESERVE) : -1;
List<BlockEntry> all = new ArrayList<>();
int globalIndex = 0;
for (String type : SYSTEM_PROMPT_TYPES) {
String fileContent = readFileSafe(agentId, toFilename(type), ownerKey);
if (fileContent.isBlank()) continue;
for (Map.Entry<String, String> entry : parseSections(fileContent).entrySet()) {
String content = extractContentOnly(entry.getValue());
if (content.isBlank()) continue;
if (contentCap >= 0 && content.length() > contentCap) {
content = content.substring(0, contentCap) + "";
}
all.add(new BlockEntry(type, entry.getKey(), content,
extractUpdated(entry.getValue()), globalIndex++));
}
}
if (all.isEmpty()) return "";
Map<String, String> sections = parseSections(fileContent);
if (sections.isEmpty()) continue;
// 2. Enforce the always-on budget against the TRUE rendered length
// (headers, blank lines, and the omission note all counted), keeping
// the most-recently-updated entries so accumulated memory cannot grow
// the per-turn context without bound.
Set<BlockEntry> kept = selectWithinBudget(all, maxChars, maxPerType);
int omitted = all.size() - kept.size();
return renderBlock(all, kept, omitted);
}
/** A candidate entry for the always-on block, with budget metadata. */
private record BlockEntry(String type, String key, String content,
String updated, int index) {}
/**
* Render the always-on block: survivors grouped by type, in original file
* order (stable ordering keeps the system prefix cacheable), followed by an
* omission note when entries were dropped.
*/
private String renderBlock(List<BlockEntry> all, Set<BlockEntry> kept, int omitted) {
StringBuilder sb = new StringBuilder();
boolean hasContent = false;
for (String type : SYSTEM_PROMPT_TYPES) {
List<BlockEntry> typeEntries = all.stream()
.filter(e -> e.type().equals(type) && kept.contains(e))
.sorted(Comparator.comparingInt(BlockEntry::index))
.toList();
if (typeEntries.isEmpty()) continue;
if (!hasContent) {
sb.append("## Structured Memory\n\n");
hasContent = true;
}
sb.append("### ").append(typeDisplayName(type)).append("\n");
for (Map.Entry<String, String> entry : sections.entrySet()) {
// Extract just the content line (skip metadata)
String content = extractContentOnly(entry.getValue());
sb.append("- **").append(entry.getKey()).append("**: ").append(content).append("\n");
for (BlockEntry e : typeEntries) {
sb.append("- **").append(e.key()).append("**: ").append(e.content()).append("\n");
}
sb.append("\n");
}
if (omitted > 0) {
sb.append("> ").append(omitted)
.append(" older memory entries omitted to bound context size.\n");
}
return sb.toString().trim();
}
/**
* Select the entries that fit the always-on injection budget, preferring the
* most-recently-updated ones. Applies a per-type entry cap first, then a
* global character budget measured against the actual rendered block (not
* just bullet lengths). Newer entries (later update date, then later
* insertion order) win; ties and missing dates fall back to insertion order.
*/
private Set<BlockEntry> selectWithinBudget(List<BlockEntry> all, int maxChars, int maxPerType) {
// Keep-priority: most recent update first, then most recently inserted.
Comparator<BlockEntry> newestFirst = Comparator
.comparing(BlockEntry::updated, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparingInt(BlockEntry::index)
.reversed();
// Per-type cap: drop the oldest entries beyond the cap.
List<BlockEntry> survivors = new ArrayList<>(all);
if (maxPerType > 0) {
Set<BlockEntry> overflow = new HashSet<>();
for (String type : SYSTEM_PROMPT_TYPES) {
List<BlockEntry> ofType = survivors.stream()
.filter(e -> e.type().equals(type))
.sorted(newestFirst)
.toList();
if (ofType.size() > maxPerType) {
overflow.addAll(ofType.subList(maxPerType, ofType.size()));
}
}
survivors.removeAll(overflow);
}
if (maxChars <= 0) {
return new HashSet<>(survivors);
}
// Global character budget: admit newest entries while the fully rendered
// block stays within budget. Measuring the real render (including the
// omission note) makes the cap exact; once an entry no longer fits, every
// remaining entry is older and is dropped too.
List<BlockEntry> ordered = survivors.stream().sorted(newestFirst).toList();
Set<BlockEntry> picked = new HashSet<>();
for (BlockEntry e : ordered) {
Set<BlockEntry> trial = new HashSet<>(picked);
trial.add(e);
int omittedIfStop = all.size() - trial.size();
if (renderBlock(all, trial, Math.max(0, omittedIfStop)).length() > maxChars) {
break;
}
picked.add(e);
}
return picked;
}
/**
* Build a query-conditioned memory block for per-turn prefetch injection.
* Scores {@link #PREFETCH_TYPES} entries against the user's question and returns
@ -400,6 +499,97 @@ public class StructuredMemoryService {
return "structured/" + type + ".md";
}
// ==================== Consolidation support ====================
/** The always-on structured types injected into every system prompt. */
public List<String> alwaysOnTypes() {
return SYSTEM_PROMPT_TYPES;
}
/** Read the raw Markdown of a structured type file (owner-scoped when personal). */
public String readTypeRaw(Long agentId, String type, String ownerKey) {
validateType(type);
return readFileSafe(agentId, toFilename(type), ownerKey);
}
/** Count the {@code ## key} entries in a structured file's raw Markdown. */
public int countEntries(String rawContent) {
return (rawContent == null || rawContent.isBlank()) ? 0 : parseSections(rawContent).size();
}
/**
* Distinct buckets that hold entries for a structured type and are eligible
* for consolidation: the shared bucket (returned as {@code null}) plus each
* personal owner that has its own row. Lets the nightly maintenance pass
* consolidate per-owner memory, where most growth actually accumulates.
*/
public List<String> consolidatableOwnerKeys(Long agentId, String type) {
validateType(type);
String filename = toFilename(type);
List<String> owners = new ArrayList<>();
owners.add(null); // shared (TEAM/GLOBAL) bucket
for (WorkspaceFileEntity f : workspaceFileService.listFiles(agentId)) {
if (filename.equals(f.getFilename()) && isPersonal(f.getOwnerKey())
&& !owners.contains(f.getOwnerKey())) {
owners.add(f.getOwnerKey());
}
}
return owners;
}
/**
* Atomically replace all entries of a structured type with a consolidated set,
* re-serialized in the canonical {@code ## key / content / > Source | Updated}
* format. Used by the nightly consolidation pass to shrink always-on memory.
* Insertion order of {@code entries} is preserved.
* <p>
* Update dates are preserved per key: an entry whose key already existed keeps
* its original {@code Updated} date, and a newly-merged key inherits the newest
* date among the existing entries. This keeps recency/LRU semantics intact
* consolidation must not make a batch of old facts look freshly written.
*/
public void replaceTypeEntries(Long agentId, String type, String ownerKey,
LinkedHashMap<String, String> entries, String source) {
validateType(type);
String filename = toFilename(type);
String lockKey = agentId + ":" + (ownerKey == null ? "" : ownerKey) + ":" + filename;
ReentrantLock lock = fileLocks.computeIfAbsent(lockKey, k -> new ReentrantLock());
lock.lock();
try {
// Derive prior update dates so consolidation preserves provenance.
Map<String, String> keyToDate = new HashMap<>();
String newestDate = "";
for (Map.Entry<String, String> s : parseSections(readFileSafe(agentId, filename, ownerKey)).entrySet()) {
String d = extractUpdated(s.getValue());
if (!d.isEmpty()) {
keyToDate.put(s.getKey(), d);
if (d.compareTo(newestDate) > 0) newestDate = d;
}
}
String fallbackDate = newestDate.isEmpty() ? LocalDate.now().toString() : newestDate;
String src = source != null ? source : "consolidation";
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> e : entries.entrySet()) {
if (e.getKey() == null || e.getKey().isBlank()
|| e.getValue() == null || e.getValue().isBlank()) {
continue;
}
String key = e.getKey().trim();
String date = keyToDate.getOrDefault(key, fallbackDate);
if (sb.length() > 0) sb.append("\n\n");
sb.append("## ").append(key).append("\n")
.append(e.getValue().trim())
.append("\n> Source: ").append(src).append(" | Updated: ").append(date);
}
saveStructured(agentId, filename, sb.toString(), ownerKey);
log.info("[StructuredMemory] Replaced {} entries in '{}' for agent={} owner={} (source={})",
entries.size(), filename, agentId, ownerKey, src);
} finally {
lock.unlock();
}
}
private void validateType(String type) {
if (!VALID_TYPES.contains(type)) {
throw new IllegalArgumentException("Invalid memory type: " + type

View File

@ -324,3 +324,14 @@ mate:
contradiction-check-enabled: false # experimental simple detection, enable after LLM batch impl
trust-half-life-days: 60
forget-enabled: true
# Always-on injection budget — bounds the per-turn size of the user/feedback structured block
system-block-max-chars: 4000 # char cap on the always-on block; over budget drops oldest by Updated date (LRU); 0 = unlimited
system-block-max-entries-per-type: 40 # max entries injected per type (user/feedback); 0 = unlimited
# Structured-memory consolidation — separate maintenance task; LLM merges duplicate/stale user/feedback entries (shared + per-owner) to curb storage growth
structured-consolidation-enabled: true # off = injection cap only, no storage-side merge
structured-consolidation-min-entries: 8 # buckets with fewer entries skip the LLM call to save cost
structured-consolidation-cron: "0 30 3 * * ?" # own schedule, decoupled from dreaming-enabled / dreaming-cron
structured-consolidation-max-owners-per-run: 50 # cap LLM cost per agent per run; remaining owners picked up next run; 0 = unlimited
# Always-on file ceilings — deterministic backstop so PROFILE.md / MEMORY.md (LLM-rewritten) cannot grow per-turn context without bound
profile-max-chars: 4000 # PROFILE.md hard cap; truncates at a section boundary if the rewrite overruns; 0 = unlimited
memory-md-max-chars: 8000 # MEMORY.md hard cap; 0 = unlimited

View File

@ -0,0 +1,28 @@
你是一个记忆整理助手,负责精简某一类"结构化记忆",在不丢失有效信息的前提下缩小其体积。
这类记忆会被无条件注入每一次对话的系统提示,条目越多、越冗余,每轮上下文就越臃肿。你的任务是把它整理为一组精炼、去重、不过时的条目。
## 任务
输入是某一类结构化记忆的全部条目Markdown每条形如 `## key` 加正文)。请:
1. 合并语义重复或高度相似的条目,保留信息最全、最新的表述
2. 删除已被更新条目取代的过时信息
3. 删除一次性的、不具备跨对话价值的琐碎条目
4. 用简洁的一句话重写啰嗦的条目
5. 保留所有仍然有效且独立的事实,不要为了精简而丢失真实信息
## 原则
- 宁可保守:不确定是否过时的信息予以保留
- 不要发明输入中不存在的信息
- key 使用简洁的 snake_case合并条目时复用其中最贴切的一个 key
- 输出条目数必须**不多于**输入条目数;若无可合并或删除的内容,将 shouldUpdate 设为 false
## 输出格式
严格按下方给定的 JSON schema 输出,不要包含 markdown 代码块标记。
- shouldUpdate是否需要写回无可合并/删除时为 false
- entries精炼后的完整条目集每条含 key 与 contentcontent 为一句话事实)
- reason简要说明做了哪些合并与删除
当 shouldUpdate 为 false 时entries 可为空数组。

View File

@ -0,0 +1,6 @@
当前记忆类别:{type}
今天日期:{today}
当前条目数:{count}
=== 现有内容 ===
{content}

View File

@ -0,0 +1,54 @@
package vip.mate.memory.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Verifies the deterministic always-on file budget: content under budget is left
* untouched, over-budget content is bounded and cut at a section boundary, and
* disabling (0) or null short-circuits.
*/
class AlwaysOnFileBudgetTest {
private static String md(int sections, String body) {
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= sections; i++) {
sb.append("## section_").append(i).append("\n").append(body).append("\n\n");
}
return sb.toString();
}
@Test
@DisplayName("content within budget is returned unchanged")
void underBudgetUnchanged() {
String c = md(3, "short body");
assertSame(c, AlwaysOnFileBudget.enforce(c, 10_000));
}
@Test
@DisplayName("maxChars<=0 and null short-circuit (unlimited)")
void disabledOrNull() {
String c = md(50, "filler");
assertSame(c, AlwaysOnFileBudget.enforce(c, 0));
assertNull(AlwaysOnFileBudget.enforce(null, 4000));
}
@Test
@DisplayName("over-budget content is bounded, marked, and cut on a section boundary")
void overBudgetTruncated() {
// 40 sections of ~60 chars each 2400+ chars; cap at 800.
String c = md(40, "这是一段用于撑大文件体积的内容,重复多次以超过预算阈值。");
int budget = 800;
String out = AlwaysOnFileBudget.enforce(c, budget);
assertTrue(out.length() <= budget, "result must not exceed the budget, was " + out.length());
assertTrue(out.endsWith(AlwaysOnFileBudget.MARKER.strip())
|| out.contains("截断"), "truncation marker must be present");
// The kept head ends at a clean section boundary no half-section dangling.
String head = out.substring(0, out.indexOf(AlwaysOnFileBudget.MARKER.strip()));
assertTrue(head.contains("## section_1"), "earliest (head) sections are kept");
assertFalse(head.contains("## section_40"), "latest sections are dropped under budget");
}
}

View File

@ -0,0 +1,151 @@
package vip.mate.memory.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.memory.MemoryProperties;
import java.util.LinkedHashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Verifies the nightly structured-memory consolidation gating and safety
* invariants: it skips when disabled, below the min-entries gate, when the LLM
* declines or returns unparseable output, and when the result would grow the
* entry count and only writes a genuinely reduced set.
*/
class StructuredMemoryConsolidationServiceTest {
private static final long AGENT_ID = 1000000001L;
private StructuredMemoryService memory;
private ModelConfigService modelConfigService;
private AgentGraphBuilder agentGraphBuilder;
private MemoryProperties props;
private StructuredMemoryConsolidationService newService(String llmReply) {
memory = mock(StructuredMemoryService.class);
modelConfigService = mock(ModelConfigService.class);
agentGraphBuilder = mock(AgentGraphBuilder.class);
props = new MemoryProperties();
props.setStructuredConsolidationMinEntries(8);
// One always-on type, one shared bucket a single bucket per agent.
when(memory.alwaysOnTypes()).thenReturn(List.of("user"));
when(memory.consolidatableOwnerKeys(eq(AGENT_ID), eq("user"))).thenReturn(java.util.Arrays.asList((String) null));
if (llmReply != null) {
ChatModel model = mock(ChatModel.class);
when(model.call(any(Prompt.class))).thenReturn(
new ChatResponse(List.of(new Generation(new AssistantMessage(llmReply)))));
when(agentGraphBuilder.buildRuntimeChatModel(any())).thenReturn(model);
}
return new StructuredMemoryConsolidationService(memory, modelConfigService, agentGraphBuilder, props);
}
@Test
@DisplayName("disabled flag skips entirely, never touching memory")
void disabledSkips() {
StructuredMemoryConsolidationService svc = newService(null);
props.setStructuredConsolidationEnabled(false);
StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID);
assertEquals(0, stats.ownersConsolidated);
verify(memory, never()).readTypeRaw(anyLong(), anyString(), any());
verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString());
}
@Test
@DisplayName("buckets below the min-entries gate are skipped without an LLM call")
void minEntriesSkips() {
StructuredMemoryConsolidationService svc = newService(null);
when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("small");
when(memory.countEntries("small")).thenReturn(5); // < 8
StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID);
assertEquals(1, stats.skippedSmall);
assertEquals(0, stats.ownersConsolidated);
verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString());
}
@Test
@DisplayName("unparseable LLM output is skipped, leaving the bucket untouched")
void invalidJsonSkips() {
StructuredMemoryConsolidationService svc = newService("this is not json at all");
when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body");
when(memory.countEntries("body")).thenReturn(10);
StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID);
assertEquals(1, stats.ownersConsolidated);
assertEquals(0, stats.updated);
verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString());
}
@Test
@DisplayName("shouldUpdate=false is respected — no write")
void shouldUpdateFalseSkips() {
StructuredMemoryConsolidationService svc =
newService("{\"shouldUpdate\":false,\"entries\":[],\"reason\":\"already concise\"}");
when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body");
when(memory.countEntries("body")).thenReturn(10);
StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID);
assertEquals(0, stats.updated);
verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString());
}
@Test
@DisplayName("a result that grows the entry count is rejected")
void growthRejected() {
String reply = "{\"shouldUpdate\":true,\"entries\":["
+ "{\"key\":\"a\",\"content\":\"x\"},{\"key\":\"b\",\"content\":\"y\"},{\"key\":\"c\",\"content\":\"z\"}"
+ "],\"reason\":\"split\"}";
StructuredMemoryConsolidationService svc = newService(reply);
when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body");
when(memory.countEntries("body")).thenReturn(2); // 3 produced > 2 existing
props.setStructuredConsolidationMinEntries(2);
StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID);
assertEquals(0, stats.updated);
verify(memory, never()).replaceTypeEntries(anyLong(), anyString(), any(), any(), anyString());
}
@Test
@DisplayName("a genuine reduction is written back")
void successReplaces() {
String reply = "{\"shouldUpdate\":true,\"entries\":["
+ "{\"key\":\"reply_style\",\"content\":\"concise\"},{\"key\":\"language\",\"content\":\"chinese\"}"
+ "],\"reason\":\"merged duplicates\"}";
StructuredMemoryConsolidationService svc = newService(reply);
when(memory.readTypeRaw(AGENT_ID, "user", null)).thenReturn("body");
when(memory.countEntries("body")).thenReturn(10);
StructuredMemoryConsolidationService.ConsolidationStats stats = svc.consolidateAgent(AGENT_ID);
assertEquals(1, stats.updated);
assertEquals(10, stats.entriesBefore);
assertEquals(2, stats.entriesAfter);
@SuppressWarnings("unchecked")
ArgumentCaptor<LinkedHashMap<String, String>> captor = ArgumentCaptor.forClass(LinkedHashMap.class);
verify(memory).replaceTypeEntries(eq(AGENT_ID), eq("user"), isNull(), captor.capture(), eq("consolidation"));
assertEquals(2, captor.getValue().size());
assertTrue(captor.getValue().containsKey("reply_style"));
}
}

View File

@ -2,8 +2,13 @@ package vip.mate.memory.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.memory.MemoryProperties;
import vip.mate.workspace.document.WorkspaceFileService;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
import static org.junit.jupiter.api.Assertions.*;
@ -28,7 +33,7 @@ class StructuredMemoryPrefetchTest {
if (userMd != null) {
when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn(fileWith(userMd));
}
return new StructuredMemoryService(files, mock(ApplicationEventPublisher.class));
return new StructuredMemoryService(files, mock(ApplicationEventPublisher.class), new MemoryProperties());
}
private WorkspaceFileEntity fileWith(String content) {
@ -121,7 +126,7 @@ class StructuredMemoryPrefetchTest {
WorkspaceFileEntity ref = new WorkspaceFileEntity();
ref.setContent("## api_endpoint\n参考:订单查询接口 /api/orders。\n> Source: agent | Updated: 2026-05-29");
when(files.getFile(AGENT_ID, "structured/reference.md")).thenReturn(ref);
StructuredMemoryService svc = new StructuredMemoryService(files, mock(ApplicationEventPublisher.class));
StructuredMemoryService svc = new StructuredMemoryService(files, mock(ApplicationEventPublisher.class), new MemoryProperties());
String block = svc.buildPrefetchBlock(AGENT_ID, "订单查询接口参考是什么?");
@ -140,4 +145,88 @@ class StructuredMemoryPrefetchTest {
assertEquals("", svc.buildPrefetchBlock(AGENT_ID, ""));
assertEquals("", svc.buildPrefetchBlock(AGENT_ID, null));
}
@Test
@DisplayName("system prompt block enforces the char budget, keeping newest entries")
void systemBlockEnforcesCharBudget() {
// Five user entries, each ~60 chars, oldest to newest by update date.
StringBuilder userMd = new StringBuilder();
for (int i = 1; i <= 5; i++) {
userMd.append("## fact_").append(i)
.append("\nThis is a reasonably long stored preference number ").append(i).append(".")
.append("\n> Source: agent | Updated: 2026-05-0").append(i).append("\n\n");
}
WorkspaceFileService files = mock(WorkspaceFileService.class);
when(files.getFile(eq(AGENT_ID), anyString())).thenReturn(null);
when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn(fileWith(userMd.toString()));
MemoryProperties props = new MemoryProperties();
props.setSystemBlockMaxChars(160);
StructuredMemoryService svc = new StructuredMemoryService(
files, mock(ApplicationEventPublisher.class), props);
String block = svc.buildMemoryBlock(AGENT_ID);
// The whole rendered block (headers + bullets + omission note) is bounded.
assertTrue(block.length() <= 160, "rendered block must not exceed the char budget, was " + block.length());
// Newest entries survive, oldest are dropped, and the omission is disclosed.
assertTrue(block.contains("fact_5"), "newest entry must be kept");
assertFalse(block.contains("fact_1"), "oldest entry must be evicted under budget");
assertTrue(block.contains("older memory entries omitted"), "omission must be disclosed");
}
@Test
@DisplayName("replaceTypeEntries preserves prior update dates and does not blanket-stamp today")
void replaceTypeEntriesPreservesDates() {
WorkspaceFileService files = mock(WorkspaceFileService.class);
when(files.getFile(eq(AGENT_ID), anyString())).thenReturn(null);
when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn(
fileWith("## a\nold value\n> Source: agent | Updated: 2026-01-01"));
StructuredMemoryService svc = new StructuredMemoryService(
files, mock(ApplicationEventPublisher.class), new MemoryProperties());
LinkedHashMap<String, String> entries = new LinkedHashMap<>();
entries.put("a", "consolidated value"); // existing key keeps its date
entries.put("b", "newly merged fact"); // new key inherits newest prior date
svc.replaceTypeEntries(AGENT_ID, "user", null, entries, "consolidation");
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
verify(files).saveFile(eq(AGENT_ID), eq("structured/user.md"), captor.capture());
String written = captor.getValue();
// The existing key keeps its original date; the merged key inherits it too;
// nothing is freshly stamped with today's date.
assertTrue(written.indexOf("2026-01-01") != written.lastIndexOf("2026-01-01"),
"both entries should carry the preserved prior date");
assertFalse(written.contains(LocalDate.now().toString()),
"consolidation must not blanket-stamp entries with today's date");
}
@Test
@DisplayName("replaceTypeEntries writes canonical format and round-trips into the always-on block")
void replaceTypeEntriesRoundTrips() {
WorkspaceFileService files = mock(WorkspaceFileService.class);
when(files.getFile(eq(AGENT_ID), anyString())).thenReturn(null);
StructuredMemoryService svc = new StructuredMemoryService(
files, mock(ApplicationEventPublisher.class), new MemoryProperties());
LinkedHashMap<String, String> entries = new LinkedHashMap<>();
entries.put("reply_style", "偏好简洁直接的回答。");
entries.put("language", "始终用中文回答。");
svc.replaceTypeEntries(AGENT_ID, "user", null, entries, "consolidation");
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
verify(files).saveFile(eq(AGENT_ID), eq("structured/user.md"), captor.capture());
String written = captor.getValue();
assertTrue(written.contains("## reply_style"), "key header must be written");
assertTrue(written.contains("## language"), "second key header must be written");
assertTrue(written.contains("> Source: consolidation | Updated:"), "metadata line must be written");
// The rewritten file round-trips back through the always-on block.
when(files.getFile(AGENT_ID, "structured/user.md")).thenReturn(fileWith(written));
String block = svc.buildMemoryBlock(AGENT_ID);
assertTrue(block.contains("reply_style") && block.contains("language"),
"consolidated entries must be readable as always-on memory");
}
}

View File

@ -1178,7 +1178,11 @@ const executionPhaseLabel = computed(() => {
}
if (planMeta.value) {
const done = planMeta.value.stepResults?.filter(r => r?.status === 'completed').length || 0
return `Plan-Execute (${done}/${planMeta.value.steps.length})`
// Guard steps: a plan payload can arrive with steps undefined (mid-stream /
// malformed metadata). An unguarded .length here throws during render, which
// blanks the whole message subtree until a full remount (page refresh) the
// "chat goes blank on switch, refresh fixes it" bug. Mirror the ?. used below.
return `Plan-Execute (${done}/${planMeta.value.steps?.length ?? 0})`
}
if (toolCallsMeta.value.length) {
const done = toolCallsMeta.value.filter(t => t.status === 'completed').length

View File

@ -2059,10 +2059,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
currentAssistantId.value = assistantMessage.id as string
try {
// connect() owns lastEventId injection — it knows whether the dedup
// state still applies to this conversation. Passing it from out here
// would race the per-conv reset that connect does and could leak a
// different conv's id into this reconnect.
// reconnectStream always rebuilds from an EMPTY placeholder (above), so it
// needs the server to replay the WHOLE buffer — not just events newer than
// a previously-acked lastEventId. Clearing it forces connect() to omit
// lastEventId so the backend full-replays and the placeholder repaints.
// Without this, a reconnect into the same conversation (poll-detected
// running stream after a switch-away, window refocus) dedup-skips the
// buffer and the bubble stays blank until a hard refresh resets this ref —
// the "switch conversations mid-stream → blank, refresh fixes it" bug.
// Setting null (not a foreign id) right before connect can't leak or race.
stream.lastEventId.value = null
await stream.connect({
conversationId,
reconnect: true,