compressionEvents = new ArrayList<>();
+
+ public AutoContextMemory(AutoContextConfig autoContextConfig, Model model) {
+ this.autoContextConfig = Objects.requireNonNull(autoContextConfig, "autoContextConfig");
+ this.model = model;
+ this.customPrompt = autoContextConfig.getCustomPrompt();
+ }
+
+ public synchronized void setModel(Model model) {
+ this.model = model;
+ }
+
+ public synchronized AutoContextConfig getAutoContextConfig() {
+ return autoContextConfig;
+ }
+
+ /**
+ * Reconciles runtime context back into the working buffer.
+ *
+ * The incoming context is expected to be either the same session prefix plus newly appended
+ * tail messages, or a rebuilt working context after a reset/reload. When the prefix still
+ * matches, only the new tail is appended into both working/original buffers; when it no longer
+ * matches, the working buffer is replaced and the original history is seeded only if it was
+ * still empty.
+ */
+ public synchronized void mergeWithContext(List context) {
+ if (context == null || context.isEmpty()) {
+ return;
+ }
+ if (workingMemoryStorage.isEmpty()) {
+ workingMemoryStorage.addAll(copyMessages(context));
+ if (originalMemoryStorage.isEmpty()) {
+ originalMemoryStorage.addAll(copyMessages(context));
+ }
+ return;
+ }
+ if (context.size() < workingMemoryStorage.size()
+ || !startsWith(context, workingMemoryStorage)) {
+ workingMemoryStorage.clear();
+ workingMemoryStorage.addAll(copyMessages(context));
+ if (originalMemoryStorage.isEmpty()) {
+ originalMemoryStorage.addAll(copyMessages(context));
+ }
+ return;
+ }
+ if (context.size() > workingMemoryStorage.size()) {
+ List tail =
+ new ArrayList<>(context.subList(workingMemoryStorage.size(), context.size()));
+ workingMemoryStorage.addAll(tail);
+ originalMemoryStorage.addAll(tail);
+ }
+ }
+
+ public synchronized AutoContextState snapshot() {
+ AutoContextState snapshot = new AutoContextState();
+ snapshot.setWorkingMessages(workingMemoryStorage);
+ snapshot.setOriginalMessages(originalMemoryStorage);
+ snapshot.setOffloadContext(offloadContext);
+ snapshot.setCompressionEvents(compressionEvents);
+ return snapshot;
+ }
+
+ public synchronized void restore(AutoContextState snapshot) {
+ if (snapshot == null) {
+ return;
+ }
+ workingMemoryStorage.clear();
+ workingMemoryStorage.addAll(copyMessages(snapshot.getWorkingMessages()));
+ originalMemoryStorage.clear();
+ originalMemoryStorage.addAll(copyMessages(snapshot.getOriginalMessages()));
+ offloadContext.clear();
+ offloadContext.putAll(copyMsgMap(snapshot.getOffloadContext()));
+ compressionEvents.clear();
+ compressionEvents.addAll(
+ snapshot.getCompressionEvents() != null
+ ? snapshot.getCompressionEvents()
+ : List.of());
+ }
+
+ @Override
+ public synchronized void saveTo(AgentStateStore stateStore, String userId, String sessionId) {
+ if (stateStore == null) {
+ return;
+ }
+ stateStore.save(userId, sessionId, STATE_KEY, snapshot());
+ }
+
+ public synchronized void saveTo(
+ AgentStateStore stateStore, String userId, String sessionId, String key) {
+ if (stateStore == null) {
+ return;
+ }
+ stateStore.save(userId, sessionId, key, snapshot());
+ }
+
+ @Override
+ public synchronized void loadFrom(AgentStateStore stateStore, String userId, String sessionId) {
+ loadFrom(stateStore, userId, sessionId, STATE_KEY);
+ }
+
+ public synchronized void loadFrom(
+ AgentStateStore stateStore, String userId, String sessionId, String key) {
+ if (stateStore == null) {
+ return;
+ }
+ stateStore.get(userId, sessionId, key, AutoContextState.class).ifPresent(this::restore);
+ }
+
+ @Override
+ public synchronized void addMessage(Msg message) {
+ if (message == null) {
+ return;
+ }
+ workingMemoryStorage.add(message);
+ originalMemoryStorage.add(message);
+ }
+
+ @Override
+ public synchronized List getMessages() {
+ return new ArrayList<>(workingMemoryStorage);
+ }
+
+ @Override
+ public synchronized void deleteMessage(int index) {
+ if (index < 0 || index >= workingMemoryStorage.size()) {
+ return;
+ }
+ workingMemoryStorage.remove(index);
+ }
+
+ public synchronized List getOriginalMemoryMsgs() {
+ return new ArrayList<>(originalMemoryStorage);
+ }
+
+ public synchronized List getInteractionMsgs() {
+ List interactions = new ArrayList<>();
+ for (Msg msg : originalMemoryStorage) {
+ if (msg.getRole() == MsgRole.USER || MsgUtils.isFinalAssistantResponse(msg)) {
+ interactions.add(msg);
+ }
+ }
+ return interactions;
+ }
+
+ public synchronized Map> getOffloadContext() {
+ return offloadContext;
+ }
+
+ public synchronized List getCompressionEvents() {
+ return compressionEvents;
+ }
+
+ public boolean compressIfNeeded() {
+ if (!isCompressionTriggered()) {
+ return false;
+ }
+
+ if (compressToolGroups()) {
+ return true;
+ }
+ if (offloadLargeMessages()) {
+ return true;
+ }
+ if (compressPreviousRounds()) {
+ return true;
+ }
+ return compressCurrentRound();
+ }
+
+ public synchronized Mono compressIfNeededAsync() {
+ return Mono.fromCallable(this::compressIfNeeded).subscribeOn(Schedulers.boundedElastic());
+ }
+
+ @Override
+ public synchronized void offload(String uuid, List messages) {
+ if (uuid == null || uuid.isBlank() || messages == null) {
+ return;
+ }
+ offloadContext.put(uuid, new ArrayList<>(messages));
+ }
+
+ @Override
+ public synchronized List reload(String uuid) {
+ List messages = offloadContext.get(uuid);
+ return messages == null ? new ArrayList<>() : new ArrayList<>(messages);
+ }
+
+ @Override
+ public synchronized void clear(String uuid) {
+ if (uuid != null) {
+ offloadContext.remove(uuid);
+ }
+ }
+
+ @Override
+ public synchronized void clear() {
+ workingMemoryStorage.clear();
+ originalMemoryStorage.clear();
+ offloadContext.clear();
+ compressionEvents.clear();
+ }
+
+ public synchronized void setWorkingMessages(List messages) {
+ workingMemoryStorage.clear();
+ workingMemoryStorage.addAll(copyMessages(messages));
+ }
+
+ private synchronized boolean isCompressionTriggered() {
+ if (workingMemoryStorage.isEmpty()) {
+ return false;
+ }
+ int tokenCount = TokenCounterUtil.calculateToken(workingMemoryStorage);
+ boolean msgCountReached =
+ workingMemoryStorage.size() >= autoContextConfig.getMsgThreshold();
+ boolean tokenReached =
+ tokenCount
+ >= (int)
+ (autoContextConfig.getMaxToken()
+ * autoContextConfig.getTokenRatio());
+ if (!msgCountReached && !tokenReached) {
+ return false;
+ }
+ return tokenCount >= autoContextConfig.getMinCompressionTokenThreshold();
+ }
+
+ private boolean compressToolGroups() {
+ boolean changed = false;
+ int cursor = 0;
+ while (true) {
+ CompressionCandidate candidate = planToolGroupCompression(cursor);
+ if (candidate == null) {
+ break;
+ }
+ if (TokenCounterUtil.calculateToken(candidate.source())
+ < autoContextConfig.getMinCompressionTokenThreshold()) {
+ cursor = candidate.end() + 1;
+ continue;
+ }
+ String summary =
+ summarizeMessages(
+ candidate.source(), candidate.prompt(), candidate.currentRound());
+ if (applyCompression(candidate, summary)) {
+ changed = true;
+ cursor = candidate.start() + 1;
+ } else {
+ cursor = candidate.end() + 1;
+ }
+ }
+ return changed;
+ }
+
+ private boolean offloadLargeMessages() {
+ CompressionCandidate candidate = planLargeMessageOffload();
+ if (candidate == null) {
+ return false;
+ }
+ String summary =
+ summarizeMessages(candidate.source(), candidate.prompt(), candidate.currentRound());
+ return applyCompression(candidate, summary);
+ }
+
+ private boolean compressPreviousRounds() {
+ boolean changed = false;
+ int guard = 0;
+ while (guard++ < 32) {
+ CompressionCandidate candidate = planPreviousRoundCompression();
+ if (candidate == null) {
+ break;
+ }
+ String summary =
+ summarizeMessages(
+ candidate.source(), candidate.prompt(), candidate.currentRound());
+ if (!applyCompression(candidate, summary)) {
+ break;
+ }
+ changed = true;
+ }
+ return changed;
+ }
+
+ private boolean compressCurrentRound() {
+ CompressionCandidate candidate = planCurrentRoundCompression();
+ if (candidate == null) {
+ return false;
+ }
+ String summary =
+ summarizeMessages(candidate.source(), candidate.prompt(), candidate.currentRound());
+ return applyCompression(candidate, summary);
+ }
+
+ private synchronized CompressionCandidate planToolGroupCompression(int cursor) {
+ int upperLimit = Math.max(0, workingMemoryStorage.size() - autoContextConfig.getLastKeep());
+ if (cursor >= upperLimit) {
+ return null;
+ }
+ IntRange range = findToolGroup(cursor, upperLimit);
+ if (range == null) {
+ return null;
+ }
+ List source =
+ new ArrayList<>(workingMemoryStorage.subList(range.start, range.end + 1));
+ return new CompressionCandidate(
+ range.start,
+ range.end,
+ CompressionEvent.TOOL_INVOCATION_COMPRESS,
+ PromptProvider.getPreviousRoundToolCompressPrompt(customPrompt),
+ source,
+ false,
+ true,
+ false);
+ }
+
+ private synchronized CompressionCandidate planLargeMessageOffload() {
+ int limit = Math.max(0, workingMemoryStorage.size() - autoContextConfig.getLastKeep());
+ for (int i = 0; i < limit; i++) {
+ Msg msg = workingMemoryStorage.get(i);
+ if (MsgUtils.calculateMessageCharCount(msg)
+ < autoContextConfig.getLargePayloadThreshold()) {
+ continue;
+ }
+ return new CompressionCandidate(
+ i,
+ i,
+ msg.getRole() == MsgRole.TOOL
+ ? CompressionEvent.LARGE_MESSAGE_OFFLOAD
+ : CompressionEvent.LARGE_MESSAGE_OFFLOAD_WITH_PROTECTION,
+ PromptProvider.getCurrentRoundLargeMessagePrompt(customPrompt),
+ List.of(msg),
+ false,
+ false,
+ true);
+ }
+ return null;
+ }
+
+ private synchronized CompressionCandidate planPreviousRoundCompression() {
+ if (workingMemoryStorage.size() <= autoContextConfig.getLastKeep() + 1) {
+ return null;
+ }
+ int end = findPrefixCompressionEnd();
+ if (end < 0) {
+ return null;
+ }
+ List source = new ArrayList<>(workingMemoryStorage.subList(0, end + 1));
+ if (TokenCounterUtil.calculateToken(source)
+ < autoContextConfig.getMinCompressionTokenThreshold()) {
+ return null;
+ }
+ return new CompressionCandidate(
+ 0,
+ end,
+ CompressionEvent.PREVIOUS_ROUND_CONVERSATION_SUMMARY,
+ PromptProvider.getPreviousRoundSummaryPrompt(customPrompt),
+ source,
+ false,
+ false,
+ false);
+ }
+
+ private synchronized CompressionCandidate planCurrentRoundCompression() {
+ int latestUserIndex = -1;
+ for (int i = workingMemoryStorage.size() - 1; i >= 0; i--) {
+ if (workingMemoryStorage.get(i).getRole() == MsgRole.USER) {
+ latestUserIndex = i;
+ break;
+ }
+ }
+ if (latestUserIndex < 0 || latestUserIndex >= workingMemoryStorage.size() - 1) {
+ return null;
+ }
+ int end = workingMemoryStorage.size() - 1;
+ if (MsgUtils.isToolUseMessage(workingMemoryStorage.get(end))) {
+ end--;
+ }
+ if (end <= latestUserIndex) {
+ return null;
+ }
+ List source =
+ new ArrayList<>(workingMemoryStorage.subList(latestUserIndex + 1, end + 1));
+ if (TokenCounterUtil.calculateToken(source)
+ < autoContextConfig.getMinCompressionTokenThreshold()) {
+ return null;
+ }
+ return new CompressionCandidate(
+ latestUserIndex + 1,
+ end,
+ CompressionEvent.CURRENT_ROUND_MESSAGE_COMPRESS,
+ PromptProvider.getCurrentRoundCompressPrompt(customPrompt),
+ source,
+ true,
+ true,
+ false);
+ }
+
+ private boolean applyCompression(CompressionCandidate candidate, String summary) {
+ if (candidate == null) {
+ return false;
+ }
+ String resolvedSummary =
+ summary == null || summary.isBlank()
+ ? fallbackSummary(candidate.source())
+ : summary.trim();
+ String uuid = UUID.randomUUID().toString();
+ Msg compressedMessage =
+ candidate.singleMessageOffload()
+ ? compressSingleMessage(candidate.source().get(0), resolvedSummary, uuid)
+ : buildSummaryMessage(
+ candidate.source(),
+ resolvedSummary,
+ uuid,
+ candidate.appendOffloadTag());
+ synchronized (this) {
+ if (!matchesCandidate(candidate)) {
+ return false;
+ }
+ offloadContext.put(uuid, copyMessages(candidate.source()));
+ MsgUtils.replaceMsg(
+ workingMemoryStorage, candidate.start(), candidate.end(), compressedMessage);
+ recordCompressionEvent(
+ candidate.eventType(),
+ candidate.start(),
+ candidate.end(),
+ candidate.source(),
+ compressedMessage,
+ buildCompressionMetadata(compressedMessage));
+ }
+ return true;
+ }
+
+ private String summarizeMessages(List messages, String prompt, boolean currentRound) {
+ if (messages == null || messages.isEmpty()) {
+ return null;
+ }
+ Model currentModel = model;
+ if (currentModel == null) {
+ return fallbackSummary(messages);
+ }
+ try {
+ List input = new ArrayList<>();
+ input.add(
+ Msg.builder()
+ .role(MsgRole.USER)
+ .name("user")
+ .content(TextBlock.builder().text(prompt).build())
+ .build());
+ input.addAll(messages);
+ input.add(
+ Msg.builder()
+ .role(MsgRole.USER)
+ .name("user")
+ .content(
+ TextBlock.builder()
+ .text(Prompts.COMPRESSION_MESSAGE_LIST_END)
+ .build())
+ .build());
+ if (currentRound) {
+ int chars = MsgUtils.calculateMessagesCharCount(messages);
+ int target =
+ Math.max(
+ 1,
+ (int)
+ (chars
+ * autoContextConfig
+ .getCurrentRoundCompressionRatio()));
+ input.add(
+ Msg.builder()
+ .role(MsgRole.USER)
+ .name("user")
+ .content(
+ TextBlock.builder()
+ .text(
+ String.format(
+ "\n"
+ + "Compress to about %d"
+ + " characters.",
+ target))
+ .build())
+ .build());
+ }
+ List responses =
+ currentModel.stream(input, null, GenerateOptions.builder().build())
+ .collectList()
+ .block();
+ if (responses == null || responses.isEmpty()) {
+ return fallbackSummary(messages);
+ }
+ ChatResponse last = responses.get(responses.size() - 1);
+ String text = extractResponseText(last);
+ return text == null || text.isBlank() ? fallbackSummary(messages) : text.trim();
+ } catch (Exception e) {
+ return fallbackSummary(messages);
+ }
+ }
+
+ private String extractResponseText(ChatResponse response) {
+ if (response == null || response.getContent() == null) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (ContentBlock block : response.getContent()) {
+ if (block instanceof TextBlock text && text.getText() != null) {
+ sb.append(text.getText());
+ }
+ }
+ return sb.length() == 0 ? null : sb.toString();
+ }
+
+ private Msg buildSummaryMessage(
+ List source, String summary, String uuid, boolean appendOffloadTag) {
+ MsgRole role = determineSummaryRole(source);
+ Map metadata = new HashMap<>();
+ Map compressMeta = new HashMap<>();
+ compressMeta.put("offloaduuid", uuid);
+ if (appendOffloadTag) {
+ compressMeta.put("compressed_current_round", Boolean.TRUE);
+ }
+ metadata.put("_compress_meta", compressMeta);
+ ChatUsage usage = null;
+ String content = summary;
+ if (appendOffloadTag) {
+ content = content + "\n" + String.format(Prompts.CONTEXT_OFFLOAD_TAG_FORMAT, uuid);
+ }
+ return Msg.builder()
+ .role(role)
+ .name(determineName(role))
+ .content(TextBlock.builder().text(content).build())
+ .metadata(metadata)
+ .usage(usage)
+ .build();
+ }
+
+ private Msg compressSingleMessage(Msg message, String summary, String uuid) {
+ List content = new ArrayList<>();
+ boolean summaryInserted = false;
+ for (ContentBlock block : message.getContent()) {
+ if (block instanceof TextBlock && !summaryInserted) {
+ content.add(TextBlock.builder().text(summary).build());
+ summaryInserted = true;
+ } else if (block instanceof ToolResultBlock toolResult) {
+ content.add(
+ ToolResultBlock.builder()
+ .name(toolResult.getName())
+ .id(toolResult.getId())
+ .output(List.of(TextBlock.builder().text(summary).build()))
+ .build());
+ } else {
+ content.add(block);
+ }
+ }
+ if (!summaryInserted) {
+ content.add(TextBlock.builder().text(summary).build());
+ }
+ Map metadata =
+ message.getMetadata() == null
+ ? new HashMap<>()
+ : new HashMap<>(message.getMetadata());
+ Map compressMeta = new HashMap<>();
+ compressMeta.put("offloaduuid", uuid);
+ metadata.put("_compress_meta", compressMeta);
+ return Msg.builder()
+ .id(message.getId())
+ .name(message.getName())
+ .role(message.getRole())
+ .content(content)
+ .metadata(metadata)
+ .timestamp(message.getTimestamp())
+ .usage(message.getUsage())
+ .build();
+ }
+
+ private Map buildCompressionMetadata(Msg msg) {
+ Map metadata = new HashMap<>();
+ metadata.put("tokenBefore", 0);
+ metadata.put("tokenAfter", 0);
+ if (msg != null && msg.getUsage() != null) {
+ metadata.put("inputToken", msg.getUsage().getInputTokens());
+ metadata.put("outputToken", msg.getUsage().getOutputTokens());
+ metadata.put("time", msg.getUsage().getTime());
+ }
+ return metadata;
+ }
+
+ private void recordCompressionEvent(
+ String eventType,
+ int startIndex,
+ int endIndex,
+ List source,
+ Msg compressedMessage,
+ Map metadata) {
+ int tokenBefore = TokenCounterUtil.calculateToken(source);
+ int tokenAfter = TokenCounterUtil.calculateToken(List.of(compressedMessage));
+ metadata.put("tokenBefore", tokenBefore);
+ metadata.put("tokenAfter", tokenAfter);
+ CompressionEvent event =
+ new CompressionEvent(
+ eventType,
+ System.currentTimeMillis(),
+ Math.max(1, endIndex - startIndex + 1),
+ startIndex > 0 && startIndex - 1 < workingMemoryStorage.size()
+ ? workingMemoryStorage.get(startIndex - 1).getId()
+ : null,
+ startIndex < workingMemoryStorage.size() - 1
+ ? workingMemoryStorage.get(startIndex + 1).getId()
+ : null,
+ compressedMessage != null ? compressedMessage.getId() : null,
+ metadata);
+ compressionEvents.add(event);
+ }
+
+ private boolean matchesCandidate(CompressionCandidate candidate) {
+ if (candidate.start() < 0
+ || candidate.end() < candidate.start()
+ || candidate.end() >= workingMemoryStorage.size()) {
+ return false;
+ }
+ List currentSlice =
+ new ArrayList<>(
+ workingMemoryStorage.subList(candidate.start(), candidate.end() + 1));
+ return currentSlice.equals(candidate.source());
+ }
+
+ private IntRange findToolGroup(int startIndex, int upperLimit) {
+ int start = -1;
+ for (int i = startIndex; i < upperLimit; i++) {
+ Msg msg = workingMemoryStorage.get(i);
+ if (MsgUtils.isToolMessage(msg)) {
+ if (start < 0) {
+ start = i;
+ }
+ } else if (start >= 0) {
+ int end = i - 1;
+ if (end - start + 1 >= autoContextConfig.getMinConsecutiveToolMessages()) {
+ return new IntRange(start, end);
+ }
+ start = -1;
+ }
+ }
+ if (start >= 0 && upperLimit - start >= autoContextConfig.getMinConsecutiveToolMessages()) {
+ return new IntRange(start, upperLimit - 1);
+ }
+ return null;
+ }
+
+ private int findPrefixCompressionEnd() {
+ int end = workingMemoryStorage.size() - autoContextConfig.getLastKeep() - 1;
+ if (end < 0) {
+ return -1;
+ }
+ while (end > 0 && MsgUtils.isToolResultMessage(workingMemoryStorage.get(end))) {
+ end--;
+ }
+ while (end > 0 && MsgUtils.isToolUseMessage(workingMemoryStorage.get(end))) {
+ end--;
+ }
+ return end;
+ }
+
+ private String fallbackSummary(List messages) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Compressed ").append(messages.size()).append(" messages.");
+ int previewLimit = autoContextConfig.getOffloadSinglePreview();
+ String preview = buildPreview(messages);
+ if (!preview.isBlank()) {
+ sb.append(" ").append(truncate(preview, previewLimit));
+ }
+ return sb.toString();
+ }
+
+ private String buildPreview(List messages) {
+ StringBuilder sb = new StringBuilder();
+ for (Msg msg : messages) {
+ if (msg == null) {
+ continue;
+ }
+ String text = msg.getTextContent();
+ if (text == null || text.isBlank()) {
+ text = msg.getContent() != null ? msg.getContent().toString() : "";
+ }
+ if (!text.isBlank()) {
+ if (!sb.isEmpty()) {
+ sb.append(" | ");
+ }
+ sb.append(text);
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String truncate(String text, int maxChars) {
+ if (text == null || maxChars <= 0 || text.length() <= maxChars) {
+ return text == null ? "" : text;
+ }
+ return text.substring(0, maxChars);
+ }
+
+ private MsgRole determineSummaryRole(List source) {
+ if (source == null || source.isEmpty()) {
+ return MsgRole.ASSISTANT;
+ }
+ boolean allTool = true;
+ for (Msg msg : source) {
+ if (msg.getRole() != MsgRole.TOOL) {
+ allTool = false;
+ break;
+ }
+ }
+ return allTool ? MsgRole.TOOL : MsgRole.ASSISTANT;
+ }
+
+ private String determineName(MsgRole role) {
+ return switch (role) {
+ case USER -> "user";
+ case TOOL -> "tool";
+ case SYSTEM, ASSISTANT -> "assistant";
+ };
+ }
+
+ private boolean startsWith(List full, List prefix) {
+ if (full == null || prefix == null || full.size() < prefix.size()) {
+ return false;
+ }
+ for (int i = 0; i < prefix.size(); i++) {
+ if (!Objects.equals(full.get(i), prefix.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private List copyMessages(List messages) {
+ return messages == null ? new ArrayList<>() : new ArrayList<>(messages);
+ }
+
+ private Map> copyMsgMap(Map> input) {
+ Map> result = new HashMap<>();
+ if (input == null) {
+ return result;
+ }
+ for (Map.Entry> entry : input.entrySet()) {
+ result.put(entry.getKey(), copyMessages(entry.getValue()));
+ }
+ return result;
+ }
+
+ private record IntRange(int start, int end) {}
+
+ private record CompressionCandidate(
+ int start,
+ int end,
+ String eventType,
+ String prompt,
+ List source,
+ boolean currentRound,
+ boolean appendOffloadTag,
+ boolean singleMessageOffload) {}
+}
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextState.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextState.java
new file mode 100644
index 000000000..521689287
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextState.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.core.memory.autocontext;
+
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.state.State;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Persisted snapshot for auto context state. */
+public class AutoContextState implements State {
+
+ private List workingMessages = new ArrayList<>();
+ private List originalMessages = new ArrayList<>();
+ private Map> offloadContext = new HashMap<>();
+ private List compressionEvents = new ArrayList<>();
+
+ public List getWorkingMessages() {
+ return workingMessages;
+ }
+
+ public void setWorkingMessages(List workingMessages) {
+ this.workingMessages =
+ workingMessages == null ? new ArrayList<>() : new ArrayList<>(workingMessages);
+ }
+
+ public List getOriginalMessages() {
+ return originalMessages;
+ }
+
+ public void setOriginalMessages(List originalMessages) {
+ this.originalMessages =
+ originalMessages == null ? new ArrayList<>() : new ArrayList<>(originalMessages);
+ }
+
+ public Map> getOffloadContext() {
+ return offloadContext;
+ }
+
+ public void setOffloadContext(Map> offloadContext) {
+ this.offloadContext =
+ offloadContext == null ? new HashMap<>() : new HashMap<>(offloadContext);
+ }
+
+ public List getCompressionEvents() {
+ return compressionEvents;
+ }
+
+ public void setCompressionEvents(List compressionEvents) {
+ this.compressionEvents =
+ compressionEvents == null ? new ArrayList<>() : new ArrayList<>(compressionEvents);
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/CompressionEvent.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/CompressionEvent.java
new file mode 100644
index 000000000..2949121f0
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/CompressionEvent.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.core.memory.autocontext;
+
+import io.agentscope.core.state.State;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Compression event record persisted by auto context. */
+public class CompressionEvent implements State {
+ public static final String TOOL_INVOCATION_COMPRESS = "TOOL_INVOCATION_COMPRESS";
+ public static final String LARGE_MESSAGE_OFFLOAD_WITH_PROTECTION =
+ "LARGE_MESSAGE_OFFLOAD_WITH_PROTECTION";
+ public static final String LARGE_MESSAGE_OFFLOAD = "LARGE_MESSAGE_OFFLOAD";
+ public static final String PREVIOUS_ROUND_CONVERSATION_SUMMARY =
+ "PREVIOUS_ROUND_CONVERSATION_SUMMARY";
+ public static final String CURRENT_ROUND_LARGE_MESSAGE_SUMMARY =
+ "CURRENT_ROUND_LARGE_MESSAGE_SUMMARY";
+ public static final String CURRENT_ROUND_MESSAGE_COMPRESS = "CURRENT_ROUND_MESSAGE_COMPRESS";
+
+ private String eventType;
+ private long timestamp;
+ private int compressedMessageCount;
+ private String previousMessageId;
+ private String nextMessageId;
+ private String compressedMessageId;
+ private Map metadata = new HashMap<>();
+
+ public CompressionEvent() {}
+
+ public CompressionEvent(
+ String eventType,
+ long timestamp,
+ int compressedMessageCount,
+ String previousMessageId,
+ String nextMessageId,
+ String compressedMessageId,
+ Map metadata) {
+ this.eventType = eventType;
+ this.timestamp = timestamp;
+ this.compressedMessageCount = compressedMessageCount;
+ this.previousMessageId = previousMessageId;
+ this.nextMessageId = nextMessageId;
+ this.compressedMessageId = compressedMessageId;
+ this.metadata = metadata != null ? new HashMap<>(metadata) : new HashMap<>();
+ }
+
+ public String getEventType() {
+ return eventType;
+ }
+
+ public void setEventType(String eventType) {
+ this.eventType = eventType;
+ }
+
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ public int getCompressedMessageCount() {
+ return compressedMessageCount;
+ }
+
+ public void setCompressedMessageCount(int compressedMessageCount) {
+ this.compressedMessageCount = compressedMessageCount;
+ }
+
+ public String getPreviousMessageId() {
+ return previousMessageId;
+ }
+
+ public void setPreviousMessageId(String previousMessageId) {
+ this.previousMessageId = previousMessageId;
+ }
+
+ public String getNextMessageId() {
+ return nextMessageId;
+ }
+
+ public void setNextMessageId(String nextMessageId) {
+ this.nextMessageId = nextMessageId;
+ }
+
+ public String getCompressedMessageId() {
+ return compressedMessageId;
+ }
+
+ public void setCompressedMessageId(String compressedMessageId) {
+ this.compressedMessageId = compressedMessageId;
+ }
+
+ public Map getMetadata() {
+ return metadata;
+ }
+
+ public void setMetadata(Map metadata) {
+ this.metadata = metadata != null ? new HashMap<>(metadata) : new HashMap<>();
+ }
+
+ public int getTokenBefore() {
+ Object value = metadata != null ? metadata.get("tokenBefore") : null;
+ return value instanceof Number n ? n.intValue() : 0;
+ }
+
+ public int getTokenAfter() {
+ Object value = metadata != null ? metadata.get("tokenAfter") : null;
+ return value instanceof Number n ? n.intValue() : 0;
+ }
+
+ public int getTokenReduction() {
+ return getTokenBefore() - getTokenAfter();
+ }
+
+ public int getCompressInputToken() {
+ Object value = metadata != null ? metadata.get("inputToken") : null;
+ return value instanceof Number n ? n.intValue() : 0;
+ }
+
+ public int getCompressOutputToken() {
+ Object value = metadata != null ? metadata.get("outputToken") : null;
+ return value instanceof Number n ? n.intValue() : 0;
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/ContextOffLoader.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/ContextOffLoader.java
new file mode 100644
index 000000000..179513f6d
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/ContextOffLoader.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.core.memory.autocontext;
+
+import io.agentscope.core.message.Msg;
+import java.util.List;
+
+/** Storage abstraction for offloaded context. */
+interface ContextOffLoader {
+ void offload(String uuid, List messages);
+
+ List reload(String uuid);
+
+ void clear(String uuid);
+}
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/ContextOffloadTool.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/ContextOffloadTool.java
new file mode 100644
index 000000000..248e7f255
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/ContextOffloadTool.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.core.memory.autocontext;
+
+import io.agentscope.core.agent.Agent;
+import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.tool.Tool;
+import io.agentscope.core.tool.ToolParam;
+import java.util.List;
+
+/** Tool for reloading offloaded context. */
+public class ContextOffloadTool {
+
+ private final ContextOffLoader contextOffLoader;
+ private final AutoContextHook hook;
+
+ public ContextOffloadTool(ContextOffLoader contextOffLoader) {
+ this.contextOffLoader = contextOffLoader;
+ this.hook = null;
+ }
+
+ ContextOffloadTool(AutoContextHook hook) {
+ this.contextOffLoader = null;
+ this.hook = hook;
+ }
+
+ public List reload(String uuid) {
+ return reloadInternal(uuid, null, null);
+ }
+
+ @Tool(
+ name = "context_reload",
+ description =
+ "Reload previously offloaded context messages by UUID. Use this tool when you"
+ + " need to access the original tool invocation history that was compressed"
+ + " and stored.")
+ public List reload(
+ @ToolParam(
+ name = "working_context_offload_uuid",
+ description = "The UUID of the offloaded context to reload.")
+ String uuid,
+ Agent agent,
+ RuntimeContext runtimeContext) {
+ return reloadInternal(uuid, agent, runtimeContext);
+ }
+
+ private List reloadInternal(String uuid, Agent agent, RuntimeContext runtimeContext) {
+ if (uuid == null || uuid.trim().isEmpty()) {
+ return List.of(errorMsg("Error: UUID cannot be null or empty."));
+ }
+
+ List messages;
+ if (hook != null) {
+ messages = hook.reload(uuid, agent, runtimeContext);
+ } else if (contextOffLoader != null) {
+ try {
+ messages = contextOffLoader.reload(uuid);
+ } catch (Exception e) {
+ return List.of(
+ errorMsg(
+ "Error reloading context with UUID "
+ + uuid
+ + ": "
+ + e.getMessage()));
+ }
+ } else {
+ return List.of(
+ errorMsg(
+ "Error: Context offloader is not available. Cannot reload context with"
+ + " UUID: "
+ + uuid));
+ }
+
+ if (messages == null || messages.isEmpty()) {
+ return List.of(
+ errorMsg(
+ "No messages found for UUID: "
+ + uuid
+ + ", The context may have been cleared or the UUID is"
+ + " invalid."));
+ }
+ return messages;
+ }
+
+ private Msg errorMsg(String text) {
+ return Msg.builder().content(TextBlock.builder().text(text).build()).build();
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/MsgUtils.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/MsgUtils.java
new file mode 100644
index 000000000..adb8c0113
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/MsgUtils.java
@@ -0,0 +1,378 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.core.memory.autocontext;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import io.agentscope.core.message.ContentBlock;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.util.JsonUtils;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Misc message utilities used by auto context. */
+public final class MsgUtils {
+
+ private static final Set PLAN_RELATED_TOOLS =
+ Set.of(
+ "create_plan",
+ "update_plan_info",
+ "revise_current_plan",
+ "update_subtask_state",
+ "finish_subtask",
+ "view_subtasks",
+ "get_subtask_count",
+ "finish_plan",
+ "view_historical_plans",
+ "recover_historical_plan");
+
+ private MsgUtils() {}
+
+ public static Object serializeMsgList(Object messages) {
+ if (!(messages instanceof List> list)) {
+ return messages;
+ }
+ List