From d5a0afdd37060cebcba5fbb6141d97e8ff6f913f Mon Sep 17 00:00:00 2001 From: Julylsh Date: Wed, 1 Apr 2026 15:46:40 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E5=BC=80=E5=8F=91`resend`=20=E6=98=AF=20`R?= =?UTF-8?q?eActAgent`=20=E7=9A=84=E9=87=8D=E6=96=B0=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=9C=A8=E4=B8=8D=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E9=97=AE=E9=A2=98=E7=9A=84=E6=83=85=E5=86=B5?= =?UTF-8?q?=E4=B8=8B=EF=BC=8C=E8=AE=A9=20Agent=20=E5=AF=B9=E4=B8=8A?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E7=94=A8=E6=88=B7=E9=97=AE=E9=A2=98=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E7=94=9F=E6=88=90=E4=B8=80=E6=AC=A1=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/io/agentscope/core/ReActAgent.java | 141 +++++++++++++++++- .../core/memory/InMemoryMemory.java | 51 +++++++ .../io/agentscope/core/memory/Memory.java | 16 ++ .../io/agentscope/core/plan/PlanNotebook.java | 32 ++++ .../io/agentscope/core/skill/SkillBox.java | 104 +++++++++++++ .../io/agentscope/core/state/StateModule.java | 31 ++++ .../core/state/StatePersistence.java | 28 +++- .../core/state/StatePersistenceTest.java | 15 +- .../memory/autocontext/AutoContextMemory.java | 29 ++++ 9 files changed, 440 insertions(+), 7 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 12a15ef17..8102e9d05 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -60,6 +60,7 @@ import io.agentscope.core.skill.SkillHook; import io.agentscope.core.state.AgentMetaState; import io.agentscope.core.state.SessionKey; +import io.agentscope.core.state.SimpleSessionKey; import io.agentscope.core.state.StatePersistence; import io.agentscope.core.state.ToolkitState; import io.agentscope.core.tool.ToolExecutionContext; @@ -67,6 +68,7 @@ import io.agentscope.core.tool.Toolkit; import io.agentscope.core.util.MessageUtils; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashSet; @@ -141,11 +143,13 @@ public class ReActAgent extends StructuredOutputCapableAgent { private final ExecutionConfig toolExecutionConfig; private final GenerateOptions generateOptions; private final PlanNotebook planNotebook; + private final SkillBox skillBox; private final ToolExecutionContext toolExecutionContext; private final StatePersistence statePersistence; + private List anchorInputMsgs; + private List anchorActiveGroups; // ==================== Constructor ==================== - private ReActAgent(Builder builder, Toolkit agentToolkit) { super( builder.name, @@ -163,6 +167,7 @@ private ReActAgent(Builder builder, Toolkit agentToolkit) { this.toolExecutionConfig = builder.toolExecutionConfig; this.generateOptions = builder.generateOptions; this.planNotebook = builder.planNotebook; + this.skillBox = builder.skillBox; this.toolExecutionContext = builder.toolExecutionContext; this.statePersistence = builder.statePersistence != null @@ -172,6 +177,89 @@ private ReActAgent(Builder builder, Toolkit agentToolkit) { // ==================== New StateModule API ==================== + /** + * Binds the session regardless of whether it exists, then loads state if it does. + * + * @param session the session to bind and potentially load from + * @param sessionKey the session identifier + * @return true if the session existed and state was loaded, false otherwise + */ + @Override + public boolean loadIfExists(Session session, SessionKey sessionKey) { + if (session.exists(sessionKey)) { + loadFrom(session, sessionKey); + return true; + } + return false; + } + + /** + * Loads agent state from the session. When {@code resend} is {@code true}, restores from the + * most recent anchor (snapshot taken before the last call) and returns the original input + * messages, allowing the caller to re-execute with {@code agent.call(inputMsgs)}. + * + * @param session the session to load from + * @param sessionKey the session key + * @param resend if {@code true}, restore from anchor instead of current state + * @return the original input messages from the anchor when resend is true; empty list otherwise + * @throws IllegalStateException if resend is true but no anchor exists + */ + public List loadIfExists(Session session, SessionKey sessionKey, boolean resend) { + if (session.exists(sessionKey)) { + loadFrom(session, sessionKey); + } + if (resend) { + if (anchorInputMsgs == null || anchorInputMsgs.isEmpty()) { + throw new IllegalStateException( + "No resend anchor found for the given session key. " + + "An anchor is automatically saved before each call()."); + } + // Restore all components from their in-memory anchors + if (statePersistence.memoryManaged()) { + // Restore memory by finding the first resend input message and + // truncating from that point, instead of using a separate anchor copy. + List currentMessages = memory.getMessages(); + String firstInputId = anchorInputMsgs.get(0).getId(); + int cutIndex = -1; + for (int i = 0; i < currentMessages.size(); i++) { + if (currentMessages.get(i).getId().equals(firstInputId)) { + cutIndex = i; + break; + } + } + if (cutIndex >= 0) { + memory.deleteMessagesFrom(cutIndex); + } + } + if (statePersistence.planNotebookManaged() && planNotebook != null) { + planNotebook.restoreAnchor(); + } + if (statePersistence.skillBoxManaged() && skillBox != null) { + skillBox.restoreAnchor(); + } + if (statePersistence.toolkitManaged() + && toolkit != null + && anchorActiveGroups != null) { + toolkit.setActiveGroups(anchorActiveGroups); + } + return new ArrayList<>(anchorInputMsgs); + } + return Collections.emptyList(); + } + + /** + * Loads agent state from the session using a string session ID. + * + * @param session the session to load from + * @param sessionId the session identifier as a string + * @param resend if {@code true}, restore from anchor instead of current state + * @return the original input messages from the anchor when resend is true; empty list otherwise + * @throws IllegalStateException if resend is true but no anchor exists + */ + public List loadIfExists(Session session, String sessionId, boolean resend) { + return loadIfExists(session, SimpleSessionKey.of(sessionId), resend); + } + /** * Save agent state to the session using the new API. * @@ -213,6 +301,22 @@ public void saveTo(Session session, SessionKey sessionKey) { if (statePersistence.planNotebookManaged() && planNotebook != null) { planNotebook.saveTo(session, sessionKey); } + + // Save SkillBox if managed + if (statePersistence.skillBoxManaged() && skillBox != null) { + skillBox.saveTo(session, sessionKey); + } + + // Save resend anchor data + if (anchorInputMsgs != null) { + session.save(sessionKey, "resend_input_msgs", anchorInputMsgs); + } + if (anchorActiveGroups != null) { + session.save( + sessionKey, + "toolkit_activeGroups_anchor", + new ToolkitState(anchorActiveGroups)); + } } /** @@ -241,6 +345,17 @@ public void loadFrom(Session session, SessionKey sessionKey) { if (statePersistence.planNotebookManaged() && planNotebook != null) { planNotebook.loadFrom(session, sessionKey); } + + // Load SkillBox if managed + if (statePersistence.skillBoxManaged() && skillBox != null) { + skillBox.loadFrom(session, sessionKey); + } + + // Load resend anchor data + List loadedAnchor = session.getList(sessionKey, "resend_input_msgs", Msg.class); + anchorInputMsgs = loadedAnchor.isEmpty() ? null : new ArrayList<>(loadedAnchor); + session.get(sessionKey, "toolkit_activeGroups_anchor", ToolkitState.class) + .ifPresent(state -> anchorActiveGroups = new ArrayList<>(state.activeGroups())); } // ==================== Protected API ==================== @@ -251,6 +366,7 @@ protected Mono doCall(List msgs) { // No pending tools -> normal processing if (pendingIds.isEmpty()) { + saveResendAnchor(msgs); addToMemory(msgs); return executeIteration(0); } @@ -721,6 +837,29 @@ private Mono handleSummaryError(Throwable error) { // ==================== Helper Methods ==================== + /** + * Save a snapshot anchor of all stateful components before each call. + * The snapshot is saved to the agent's bound Session, so it persists + * regardless of the Session implementation (InMemory, Json, MySQL, etc.) + * + * @param inputMsgs the input messages from this call + */ + private void saveResendAnchor(List inputMsgs) { + this.anchorInputMsgs = new ArrayList<>(inputMsgs); + if (statePersistence.memoryManaged()) { + memory.saveAnchor(); + } + if (statePersistence.planNotebookManaged() && planNotebook != null) { + planNotebook.saveAnchor(); + } + if (statePersistence.skillBoxManaged() && skillBox != null) { + skillBox.saveAnchor(); + } + if (statePersistence.toolkitManaged() && toolkit != null) { + anchorActiveGroups = new ArrayList<>(toolkit.getActiveGroups()); + } + } + /** * Prepare messages for model input. */ diff --git a/agentscope-core/src/main/java/io/agentscope/core/memory/InMemoryMemory.java b/agentscope-core/src/main/java/io/agentscope/core/memory/InMemoryMemory.java index 869828408..88f8078ac 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/memory/InMemoryMemory.java +++ b/agentscope-core/src/main/java/io/agentscope/core/memory/InMemoryMemory.java @@ -34,6 +34,8 @@ public class InMemoryMemory implements Memory { private final List messages = new CopyOnWriteArrayList<>(); + private List anchorMessages; + /** Key prefix for storage. */ private static final String KEY_PREFIX = "memory"; @@ -124,4 +126,53 @@ public void deleteMessage(int index) { public void clear() { messages.clear(); } + + /** + * Deletes all messages from the specified index (inclusive) to the end. + * + *

This implementation uses {@link List#subList} for efficient bulk removal, + * avoiding per-element deletion overhead. If {@code fromIndex} is out of bounds + * (negative or >= size), this operation is a no-op (no exception thrown). + * + * @param fromIndex The start index (inclusive, 0-based) + */ + @Override + public void deleteMessagesFrom(int fromIndex) { + int size = messages.size(); + if (fromIndex < 0 || fromIndex >= size) { + return; + } + messages.subList(fromIndex, size).clear(); + } + + // ==================== Anchor Implementation ==================== + + /** + * Saves the current message list as an anchor point. + */ + @Override + public void saveAnchor() { + anchorMessages = new ArrayList<>(messages); + } + + /** + * Restores messages to the previously saved anchor point. + */ + @Override + public void restoreAnchor() { + if (anchorMessages != null) { + messages.clear(); + messages.addAll(anchorMessages); + } + } + + /** + * Returns whether an anchor point has been saved. + * + * @return true if an anchor exists, false otherwise + */ + @Override + public boolean hasAnchor() { + return anchorMessages != null; + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/memory/Memory.java b/agentscope-core/src/main/java/io/agentscope/core/memory/Memory.java index 4b223fedf..dafe957b4 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/memory/Memory.java +++ b/agentscope-core/src/main/java/io/agentscope/core/memory/Memory.java @@ -59,4 +59,20 @@ public interface Memory extends StateModule { * is typically irreversible unless state has been persisted. */ void clear(); + + /** + * Deletes all messages from the specified index (inclusive) to the end. + * + *

If the index is out of bounds (negative or >= size), this operation should be a no-op + * rather than throwing an exception. + * + * @param fromIndex The start index (inclusive, 0-based) + */ + default void deleteMessagesFrom(int fromIndex) { + List messages = getMessages(); + int size = messages.size(); + for (int i = size - 1; i >= fromIndex && i >= 0; i--) { + deleteMessage(i); + } + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/plan/PlanNotebook.java b/agentscope-core/src/main/java/io/agentscope/core/plan/PlanNotebook.java index 473992c66..3fc166968 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/plan/PlanNotebook.java +++ b/agentscope-core/src/main/java/io/agentscope/core/plan/PlanNotebook.java @@ -114,6 +114,8 @@ public class PlanNotebook implements StateModule { + "'finish_plan' function."; private Plan currentPlan; + private Plan anchorPlan; + private boolean hasAnchorFlag; private final PlanToHint planToHint; private final PlanStorage storage; private final Integer maxSubtasks; @@ -158,6 +160,10 @@ public static Builder builder() { public void saveTo(Session session, SessionKey sessionKey) { // Always save, even when null, to ensure cleared state is persisted session.save(sessionKey, keyPrefix + "_state", new PlanNotebookState(currentPlan)); + if (hasAnchorFlag) { + session.save( + sessionKey, keyPrefix + "_state_anchor", new PlanNotebookState(anchorPlan)); + } } /** @@ -172,6 +178,32 @@ public void loadFrom(Session session, SessionKey sessionKey) { this.currentPlan = null; session.get(sessionKey, keyPrefix + "_state", PlanNotebookState.class) .ifPresent(state -> this.currentPlan = state.currentPlan()); + hasAnchorFlag = false; + anchorPlan = null; + session.get(sessionKey, keyPrefix + "_state_anchor", PlanNotebookState.class) + .ifPresent( + state -> { + anchorPlan = state.currentPlan(); + hasAnchorFlag = true; + }); + } + + @Override + public void saveAnchor() { + anchorPlan = currentPlan; + hasAnchorFlag = true; + } + + @Override + public void restoreAnchor() { + if (hasAnchorFlag) { + currentPlan = anchorPlan; + } + } + + @Override + public boolean hasAnchor() { + return hasAnchorFlag; } /** Builder for constructing PlanNotebook instances with customizable settings. */ diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java index 33f6d007f..a131a8e8c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java @@ -15,7 +15,10 @@ */ package io.agentscope.core.skill; +import io.agentscope.core.session.Session; import io.agentscope.core.skill.util.SkillFileSystemHelper; +import io.agentscope.core.state.SessionKey; +import io.agentscope.core.state.SkillBoxState; import io.agentscope.core.state.StateModule; import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.ExtendedModel; @@ -34,6 +37,7 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.Base64; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -46,6 +50,7 @@ public class SkillBox implements StateModule { private static final String BASE64_PREFIX = "base64:"; private final SkillRegistry skillRegistry = new SkillRegistry(); + private Map anchorSkillStates; private final AgentSkillPromptProvider skillPromptProvider; private final SkillToolFactory skillToolFactory; private Toolkit toolkit; @@ -292,6 +297,105 @@ public void deactivateAllSkills() { logger.debug("Deactivated all skills"); } + /** + * Save SkillBox state to the session. + * + *

Saves the current activation state of all registered skills. + * + * @param session the session to save state to + * @param sessionKey the session identifier + */ + @Override + public void saveTo(Session session, SessionKey sessionKey) { + Map skillActivationStates = new HashMap<>(); + for (Map.Entry entry : + skillRegistry.getAllRegisteredSkills().entrySet()) { + skillActivationStates.put(entry.getKey(), entry.getValue().isActive()); + } + session.save(sessionKey, "skillbox_state", new SkillBoxState(skillActivationStates)); + if (anchorSkillStates != null) { + session.save(sessionKey, "skillbox_state_anchor", new SkillBoxState(anchorSkillStates)); + } + } + + /** + * Load SkillBox state from the session. + * + *

Restores skill activation states. Only restores states for skills + * that are currently registered. + * + * @param session the session to load state from + * @param sessionKey the session identifier + */ + @Override + public void loadFrom(Session session, SessionKey sessionKey) { + session.get(sessionKey, "skillbox_state", SkillBoxState.class) + .ifPresent( + state -> { + if (state.skillActivationStates() != null) { + for (Map.Entry entry : + state.skillActivationStates().entrySet()) { + RegisteredSkill skill = + skillRegistry.getRegisteredSkill(entry.getKey()); + if (skill != null) { + skill.setActive(entry.getValue()); + } + } + syncToolGroupStates(); + } + }); + anchorSkillStates = null; + session.get(sessionKey, "skillbox_state_anchor", SkillBoxState.class) + .ifPresent( + state -> { + if (state.skillActivationStates() != null) { + anchorSkillStates = new HashMap<>(state.skillActivationStates()); + } + }); + } + + /** + * Saves a snapshot of the current skill activation states as an anchor. + * + *

The anchor can later be restored via {@link #restoreAnchor()}. + */ + @Override + public void saveAnchor() { + anchorSkillStates = new HashMap<>(); + for (Map.Entry entry : + skillRegistry.getAllRegisteredSkills().entrySet()) { + anchorSkillStates.put(entry.getKey(), entry.getValue().isActive()); + } + } + + /** + * Restores the skill activation states from the previously saved anchor. + * + *

If no anchor has been saved, this method does nothing. + */ + @Override + public void restoreAnchor() { + if (anchorSkillStates != null) { + for (Map.Entry entry : anchorSkillStates.entrySet()) { + RegisteredSkill skill = skillRegistry.getRegisteredSkill(entry.getKey()); + if (skill != null) { + skill.setActive(entry.getValue()); + } + } + syncToolGroupStates(); + } + } + + /** + * Returns whether an anchor has been saved. + * + * @return true if an anchor exists, false otherwise + */ + @Override + public boolean hasAnchor() { + return anchorSkillStates != null; + } + /** * Fluent builder for registering skills with optional configuration. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/state/StateModule.java b/agentscope-core/src/main/java/io/agentscope/core/state/StateModule.java index 6f4338286..f3f8bb218 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/state/StateModule.java +++ b/agentscope-core/src/main/java/io/agentscope/core/state/StateModule.java @@ -117,4 +117,35 @@ default boolean loadIfExists(Session session, SessionKey sessionKey) { default boolean loadIfExists(Session session, String sessionId) { return loadIfExists(session, SimpleSessionKey.of(sessionId)); } + + /** + * Saves the current state as an in-memory anchor (snapshot). + * + *

This anchor can later be restored via {@link #restoreAnchor()}. Each call overwrites + * the previous anchor. The anchor is purely in-memory; to persist it across restarts, + * implementations should include anchor data in their {@link #saveTo(Session, SessionKey)} + * output. + */ + default void saveAnchor() { + // Default no-op. Subclasses should override to snapshot their current state. + } + + /** + * Restores the state from the most recent in-memory anchor saved by {@link #saveAnchor()}. + * + *

After this call, the component's state should match what it was when + * {@link #saveAnchor()} was last called. + */ + default void restoreAnchor() { + // Default no-op. Subclasses should override to restore from their snapshot. + } + + /** + * Checks whether an in-memory anchor exists. + * + * @return {@code true} if {@link #saveAnchor()} has been called and an anchor is available + */ + default boolean hasAnchor() { + return false; + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java b/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java index 52f18b23e..94008ee82 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java +++ b/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java @@ -64,6 +64,7 @@ * @param toolkitManaged whether to manage Toolkit activeGroups state * @param planNotebookManaged whether to manage PlanNotebook state * @param statefulToolsManaged whether to manage stateful Tool states + * @param skillBoxManaged whether to manage SkillBox state * @see StateModule * @see io.agentscope.core.ReActAgent */ @@ -71,21 +72,22 @@ public record StatePersistence( boolean memoryManaged, boolean toolkitManaged, boolean planNotebookManaged, - boolean statefulToolsManaged) { + boolean statefulToolsManaged, + boolean skillBoxManaged) { /** Default configuration: manage all components. */ public static StatePersistence all() { - return new StatePersistence(true, true, true, true); + return new StatePersistence(true, true, true, true, true); } /** Don't manage any components (user fully controls). */ public static StatePersistence none() { - return new StatePersistence(false, false, false, false); + return new StatePersistence(false, false, false, false, false); } /** Only manage Memory component. */ public static StatePersistence memoryOnly() { - return new StatePersistence(true, false, false, false); + return new StatePersistence(true, false, false, false, false); } /** @@ -104,6 +106,7 @@ public static class Builder { private boolean toolkitManaged = true; private boolean planNotebookManaged = true; private boolean statefulToolsManaged = true; + private boolean skillBoxManaged = true; /** * Sets whether to manage Memory component state. @@ -149,6 +152,17 @@ public Builder statefulToolsManaged(boolean managed) { return this; } + /** + * Sets whether to manage SkillBox state. + * + * @param managed true to manage SkillBox state, false to let user manage + * @return This builder for method chaining + */ + public Builder skillBoxManaged(boolean managed) { + this.skillBoxManaged = managed; + return this; + } + /** * Builds a new StatePersistence with the configured settings. * @@ -156,7 +170,11 @@ public Builder statefulToolsManaged(boolean managed) { */ public StatePersistence build() { return new StatePersistence( - memoryManaged, toolkitManaged, planNotebookManaged, statefulToolsManaged); + memoryManaged, + toolkitManaged, + planNotebookManaged, + statefulToolsManaged, + skillBoxManaged); } } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java b/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java index 6130f8088..2fc5d8c15 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java @@ -39,6 +39,7 @@ void testAllFactory() { assertTrue(persistence.toolkitManaged()); assertTrue(persistence.planNotebookManaged()); assertTrue(persistence.statefulToolsManaged()); + assertTrue(persistence.skillBoxManaged()); } @Test @@ -49,6 +50,7 @@ void testNoneFactory() { assertFalse(persistence.toolkitManaged()); assertFalse(persistence.planNotebookManaged()); assertFalse(persistence.statefulToolsManaged()); + assertFalse(persistence.skillBoxManaged()); } @Test @@ -59,6 +61,7 @@ void testMemoryOnlyFactory() { assertFalse(persistence.toolkitManaged()); assertFalse(persistence.planNotebookManaged()); assertFalse(persistence.statefulToolsManaged()); + assertFalse(persistence.skillBoxManaged()); } } @@ -74,6 +77,7 @@ void testBuilderDefaults() { assertTrue(persistence.toolkitManaged()); assertTrue(persistence.planNotebookManaged()); assertTrue(persistence.statefulToolsManaged()); + assertTrue(persistence.skillBoxManaged()); } @Test @@ -84,6 +88,7 @@ void testBuilderDisableMemory() { assertTrue(persistence.toolkitManaged()); assertTrue(persistence.planNotebookManaged()); assertTrue(persistence.statefulToolsManaged()); + assertTrue(persistence.skillBoxManaged()); } @Test @@ -94,6 +99,7 @@ void testBuilderDisableToolkit() { assertFalse(persistence.toolkitManaged()); assertTrue(persistence.planNotebookManaged()); assertTrue(persistence.statefulToolsManaged()); + assertTrue(persistence.skillBoxManaged()); } @Test @@ -105,6 +111,7 @@ void testBuilderDisablePlanNotebook() { assertTrue(persistence.toolkitManaged()); assertFalse(persistence.planNotebookManaged()); assertTrue(persistence.statefulToolsManaged()); + assertTrue(persistence.skillBoxManaged()); } @Test @@ -116,6 +123,7 @@ void testBuilderDisableStatefulTools() { assertTrue(persistence.toolkitManaged()); assertTrue(persistence.planNotebookManaged()); assertFalse(persistence.statefulToolsManaged()); + assertTrue(persistence.skillBoxManaged()); } @Test @@ -127,11 +135,13 @@ void testBuilderDisableAll() { .toolkitManaged(false) .planNotebookManaged(false) .statefulToolsManaged(false) + .skillBoxManaged(false) .build(); assertFalse(persistence.memoryManaged()); assertFalse(persistence.toolkitManaged()); assertFalse(persistence.planNotebookManaged()); assertFalse(persistence.statefulToolsManaged()); + assertFalse(persistence.skillBoxManaged()); } @Test @@ -143,11 +153,13 @@ void testBuilderChaining() { .toolkitManaged(false) .planNotebookManaged(true) .statefulToolsManaged(false) + .skillBoxManaged(true) .build(); assertTrue(persistence.memoryManaged()); assertFalse(persistence.toolkitManaged()); assertTrue(persistence.planNotebookManaged()); assertFalse(persistence.statefulToolsManaged()); + assertTrue(persistence.skillBoxManaged()); } } @@ -167,11 +179,12 @@ void testEquality() { @Test @DisplayName("Constructor should accept all boolean values") void testConstructor() { - StatePersistence persistence = new StatePersistence(true, false, true, false); + StatePersistence persistence = new StatePersistence(true, false, true, false, true); assertTrue(persistence.memoryManaged()); assertFalse(persistence.toolkitManaged()); assertTrue(persistence.planNotebookManaged()); assertFalse(persistence.statefulToolsManaged()); + assertTrue(persistence.skillBoxManaged()); } } } diff --git a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextMemory.java b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextMemory.java index 4c450821a..facb9ca24 100644 --- a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextMemory.java +++ b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/AutoContextMemory.java @@ -1400,6 +1400,35 @@ public void deleteMessage(int index) { } } + /** + * Deletes all messages from the specified index (inclusive) to the end of the list. + * + *

Unlike the default Memory implementation, this method also truncates + * {@code originalMemoryStorage} by finding the matching message ID at the cut point. + * This ensures consistency between working and original storage during resend operations. + * + * @param fromIndex the starting index (inclusive) in working memory from which to delete + */ + @Override + public void deleteMessagesFrom(int fromIndex) { + if (fromIndex < 0 || fromIndex >= workingMemoryStorage.size()) { + return; + } + // Get the ID of the message at the cut point in working memory + String cutMsgId = workingMemoryStorage.get(fromIndex).getId(); + + // Truncate workingMemoryStorage + workingMemoryStorage.subList(fromIndex, workingMemoryStorage.size()).clear(); + + // Find the same message in originalMemoryStorage by ID and truncate + for (int i = 0; i < originalMemoryStorage.size(); i++) { + if (originalMemoryStorage.get(i).getId().equals(cutMsgId)) { + originalMemoryStorage.subList(i, originalMemoryStorage.size()).clear(); + break; + } + } + } + /** * Extract tool messages from raw messages for compression. * From 4f9862bef6c868fc4487134434343194c059c6fc Mon Sep 17 00:00:00 2001 From: Julylsh Date: Wed, 1 Apr 2026 17:24:32 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(state):=20=E6=B7=BB=E5=8A=A0=20SkillBo?= =?UTF-8?q?xState=20=E7=8A=B6=E6=80=81=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentscope/core/state/SkillBoxState.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/state/SkillBoxState.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/state/SkillBoxState.java b/agentscope-core/src/main/java/io/agentscope/core/state/SkillBoxState.java new file mode 100644 index 000000000..8a0728791 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/state/SkillBoxState.java @@ -0,0 +1,29 @@ +/* + * 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.state; + +import java.util.Map; + +/** + * State record for SkillBox activation states. + * + *

This record captures the runtime activation state of all registered skills. + * The registered skills themselves (AgentSkill objects) are not persisted, + * as they are configured at agent setup time. Only runtime activation states are saved. + * + * @param skillActivationStates Map of skill ID to activation state (true = active) + */ +public record SkillBoxState(Map skillActivationStates) implements State {} From 81e0a52fba415dcc00c3e8c568d3281b0304f4d7 Mon Sep 17 00:00:00 2001 From: Julylsh Date: Wed, 1 Apr 2026 17:58:03 +0800 Subject: [PATCH 3/5] =?UTF-8?q?ReActAgent=E7=9A=84bug=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/io/agentscope/core/ReActAgent.java | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index c4b78c530..48f552b4e 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -185,22 +185,6 @@ private ReActAgent(Builder builder, Toolkit agentToolkit) { // ==================== New StateModule API ==================== - /** - * Binds the session regardless of whether it exists, then loads state if it does. - * - * @param session the session to bind and potentially load from - * @param sessionKey the session identifier - * @return true if the session existed and state was loaded, false otherwise - */ - @Override - public boolean loadIfExists(Session session, SessionKey sessionKey) { - if (session.exists(sessionKey)) { - loadFrom(session, sessionKey); - return true; - } - return false; - } - /** * Loads agent state from the session. When {@code resend} is {@code true}, restores from the * most recent anchor (snapshot taken before the last call) and returns the original input From 5c9b1a69bd9edacbf3770bbba607b49d9bfd5c3e Mon Sep 17 00:00:00 2001 From: Julylsh Date: Wed, 1 Apr 2026 18:31:47 +0800 Subject: [PATCH 4/5] =?UTF-8?q?ReActAgent=E7=9A=84bug=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/io/agentscope/core/ReActAgent.java | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index c4b78c530..48f552b4e 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -185,22 +185,6 @@ private ReActAgent(Builder builder, Toolkit agentToolkit) { // ==================== New StateModule API ==================== - /** - * Binds the session regardless of whether it exists, then loads state if it does. - * - * @param session the session to bind and potentially load from - * @param sessionKey the session identifier - * @return true if the session existed and state was loaded, false otherwise - */ - @Override - public boolean loadIfExists(Session session, SessionKey sessionKey) { - if (session.exists(sessionKey)) { - loadFrom(session, sessionKey); - return true; - } - return false; - } - /** * Loads agent state from the session. When {@code resend} is {@code true}, restores from the * most recent anchor (snapshot taken before the last call) and returns the original input From c7455107581fb4b2f87e82e34dfd2cf85730988b Mon Sep 17 00:00:00 2001 From: Julylsh Date: Wed, 1 Apr 2026 19:47:57 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test(autocontext-memory):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0AutoContextMemory=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=A4=9A=E7=A7=8D=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/memory/InMemoryMemoryTest.java | 203 ++++++++++++++++++ .../plan/PlanNotebookStateModuleTest.java | 200 +++++++++++++++++ .../agentscope/core/skill/SkillBoxTest.java | 181 ++++++++++++++++ .../core/state/StatePersistenceTest.java | 76 +++++++ .../autocontext/AutoContextMemoryTest.java | 140 ++++++++++++ 5 files changed, 800 insertions(+) diff --git a/agentscope-core/src/test/java/io/agentscope/core/memory/InMemoryMemoryTest.java b/agentscope-core/src/test/java/io/agentscope/core/memory/InMemoryMemoryTest.java index c571afea9..e440080a2 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/memory/InMemoryMemoryTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/memory/InMemoryMemoryTest.java @@ -16,6 +16,7 @@ package io.agentscope.core.memory; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -23,6 +24,8 @@ import io.agentscope.core.message.Msg; import java.util.List; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class InMemoryMemoryTest { @@ -173,4 +176,204 @@ void testConcurrentOperations() { memory.clear(); assertTrue(memory.getMessages().isEmpty()); } + + // ==================== Anchor Tests ==================== + + @Nested + @DisplayName("Anchor (saveAnchor / restoreAnchor / hasAnchor)") + class AnchorTests { + + @Test + @DisplayName("hasAnchor() returns false before saveAnchor() is called") + void testHasAnchorFalseInitially() { + assertFalse(memory.hasAnchor()); + } + + @Test + @DisplayName("hasAnchor() returns true after saveAnchor() is called") + void testHasAnchorTrueAfterSave() { + memory.addMessage(TestUtils.createUserMessage("user", "Hello")); + memory.saveAnchor(); + assertTrue(memory.hasAnchor()); + } + + @Test + @DisplayName("saveAnchor() snapshots current messages and restoreAnchor() restores them") + void testSaveAndRestoreAnchor() { + Msg msg1 = TestUtils.createUserMessage("user", "Message 1"); + Msg msg2 = TestUtils.createAssistantMessage("assistant", "Message 2"); + memory.addMessage(msg1); + memory.addMessage(msg2); + + memory.saveAnchor(); + + // Add more messages after anchor + memory.addMessage(TestUtils.createUserMessage("user", "Message 3")); + assertEquals(3, memory.getMessages().size()); + + // Restore should revert to the 2-message state + memory.restoreAnchor(); + List restored = memory.getMessages(); + assertEquals(2, restored.size()); + assertEquals(msg1, restored.get(0)); + assertEquals(msg2, restored.get(1)); + } + + @Test + @DisplayName("restoreAnchor() is a no-op when no anchor has been saved") + void testRestoreAnchorNoOpWhenNoAnchor() { + memory.addMessage(TestUtils.createUserMessage("user", "Only message")); + // Should not throw, should not change messages + memory.restoreAnchor(); + assertEquals(1, memory.getMessages().size()); + } + + @Test + @DisplayName("saveAnchor() overwrites previous anchor") + void testSaveAnchorOverwritesPrevious() { + Msg msg1 = TestUtils.createUserMessage("user", "First"); + memory.addMessage(msg1); + memory.saveAnchor(); // anchor = [msg1] + + Msg msg2 = TestUtils.createAssistantMessage("assistant", "Second"); + memory.addMessage(msg2); + memory.saveAnchor(); // anchor = [msg1, msg2] + + // Add a third message and restore + memory.addMessage(TestUtils.createUserMessage("user", "Third")); + memory.restoreAnchor(); + + // Should be [msg1, msg2] + List restored = memory.getMessages(); + assertEquals(2, restored.size()); + assertEquals(msg2, restored.get(1)); + } + + @Test + @DisplayName("restoreAnchor() can be called multiple times idempotently") + void testRestoreAnchorMultipleTimes() { + Msg msg1 = TestUtils.createUserMessage("user", "Anchor message"); + memory.addMessage(msg1); + memory.saveAnchor(); + + memory.addMessage(TestUtils.createUserMessage("user", "Extra")); + memory.restoreAnchor(); + assertEquals(1, memory.getMessages().size()); + + // Calling again should still work + memory.addMessage(TestUtils.createUserMessage("user", "Extra2")); + memory.restoreAnchor(); + assertEquals(1, memory.getMessages().size()); + } + + @Test + @DisplayName("saveAnchor() on empty memory, restoreAnchor() clears messages") + void testSaveAnchorOnEmptyRestoresClear() { + memory.saveAnchor(); // anchor = empty + + memory.addMessage(TestUtils.createUserMessage("user", "Added after anchor")); + assertEquals(1, memory.getMessages().size()); + + memory.restoreAnchor(); + assertTrue(memory.getMessages().isEmpty()); + } + } + + // ==================== deleteMessagesFrom Tests ==================== + + @Nested + @DisplayName("deleteMessagesFrom(int fromIndex)") + class DeleteMessagesFromTests { + + @Test + @DisplayName("Normal truncation: removes messages from index to end") + void testDeleteMessagesFromNormal() { + memory.addMessage(TestUtils.createUserMessage("user", "Msg 0")); + memory.addMessage(TestUtils.createUserMessage("user", "Msg 1")); + memory.addMessage(TestUtils.createUserMessage("user", "Msg 2")); + memory.addMessage(TestUtils.createUserMessage("user", "Msg 3")); + + memory.deleteMessagesFrom(2); + + List remaining = memory.getMessages(); + assertEquals(2, remaining.size()); + assertEquals("Msg 0", TestUtils.extractTextContent(remaining.get(0))); + assertEquals("Msg 1", TestUtils.extractTextContent(remaining.get(1))); + } + + @Test + @DisplayName("fromIndex = 0 removes all messages") + void testDeleteMessagesFromZero() { + memory.addMessage(TestUtils.createUserMessage("user", "Msg 0")); + memory.addMessage(TestUtils.createUserMessage("user", "Msg 1")); + + memory.deleteMessagesFrom(0); + + assertTrue(memory.getMessages().isEmpty()); + } + + @Test + @DisplayName("fromIndex = size - 1 removes only the last message") + void testDeleteMessagesFromLastIndex() { + memory.addMessage(TestUtils.createUserMessage("user", "Msg 0")); + memory.addMessage(TestUtils.createUserMessage("user", "Msg 1")); + memory.addMessage(TestUtils.createUserMessage("user", "Msg 2")); + + memory.deleteMessagesFrom(2); + + List remaining = memory.getMessages(); + assertEquals(2, remaining.size()); + assertEquals("Msg 1", TestUtils.extractTextContent(remaining.get(1))); + } + + @Test + @DisplayName("fromIndex = -1 is a no-op") + void testDeleteMessagesFromNegativeIndex() { + memory.addMessage(TestUtils.createUserMessage("user", "Msg 0")); + memory.addMessage(TestUtils.createUserMessage("user", "Msg 1")); + + memory.deleteMessagesFrom(-1); + + assertEquals(2, memory.getMessages().size()); + } + + @Test + @DisplayName("fromIndex = size is a no-op") + void testDeleteMessagesFromSizeIndex() { + memory.addMessage(TestUtils.createUserMessage("user", "Msg 0")); + memory.addMessage(TestUtils.createUserMessage("user", "Msg 1")); + + memory.deleteMessagesFrom( + 2); // size == 2, valid; actually removes from index 2 (nothing) + // Wait - fromIndex == size is actually no-op per impl: fromIndex >= size returns + // Actually size=2, fromIndex=2: condition fromIndex >= size is 2>=2 = true => no-op + // Let's re-add and test fromIndex == size+1 + // First restore to 2 messages + memory.addMessage(TestUtils.createUserMessage("user", "Msg 0 again")); + // Now size=3, let's test fromIndex=4 + memory.deleteMessagesFrom(4); + assertEquals(3, memory.getMessages().size()); + } + + @Test + @DisplayName("fromIndex >= size is a no-op") + void testDeleteMessagesFromOutOfBounds() { + memory.addMessage(TestUtils.createUserMessage("user", "Msg 0")); + memory.addMessage(TestUtils.createUserMessage("user", "Msg 1")); + // size = 2, fromIndex = 2 → no-op (boundary) + memory.deleteMessagesFrom(2); + assertEquals(2, memory.getMessages().size()); + + // fromIndex = 10 → no-op + memory.deleteMessagesFrom(10); + assertEquals(2, memory.getMessages().size()); + } + + @Test + @DisplayName("deleteMessagesFrom on empty memory is a no-op") + void testDeleteMessagesFromEmptyMemory() { + memory.deleteMessagesFrom(0); + assertTrue(memory.getMessages().isEmpty()); + } + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/plan/PlanNotebookStateModuleTest.java b/agentscope-core/src/test/java/io/agentscope/core/plan/PlanNotebookStateModuleTest.java index 0f8ffa4ce..aba8f1eb8 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/plan/PlanNotebookStateModuleTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/plan/PlanNotebookStateModuleTest.java @@ -16,6 +16,7 @@ package io.agentscope.core.plan; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -222,4 +223,203 @@ void testSaveAfterModification() { assertEquals("Modified Plan", freshLoad.getCurrentPlan().getName()); } } + + // ==================== Anchor Tests ==================== + + @Nested + @DisplayName("Anchor (saveAnchor / restoreAnchor / hasAnchor)") + class AnchorTests { + + @Test + @DisplayName("hasAnchor() returns false before saveAnchor() is called") + void testHasAnchorFalseInitially() { + PlanNotebook notebook = PlanNotebook.builder().build(); + assertFalse(notebook.hasAnchor()); + } + + @Test + @DisplayName("hasAnchor() returns true after saveAnchor() is called") + void testHasAnchorTrueAfterSave() { + PlanNotebook notebook = PlanNotebook.builder().build(); + notebook.createPlanWithSubTasks( + "Plan", "Desc", "Outcome", List.of(new SubTask("T", "D", "O"))) + .block(); + notebook.saveAnchor(); + assertTrue(notebook.hasAnchor()); + } + + @Test + @DisplayName("saveAnchor() with no plan and restoreAnchor() sets currentPlan to null") + void testSaveAnchorNullPlanRestoresClear() { + PlanNotebook notebook = PlanNotebook.builder().build(); + notebook.saveAnchor(); // anchor = null plan + + // Create a plan + notebook.createPlanWithSubTasks( + "New Plan", "Desc", "Outcome", List.of(new SubTask("T", "D", "O"))) + .block(); + assertNotNull(notebook.getCurrentPlan()); + + notebook.restoreAnchor(); + assertNull(notebook.getCurrentPlan()); + } + + @Test + @DisplayName("saveAnchor() snapshots currentPlan, restoreAnchor() restores it") + void testSaveAndRestoreAnchorWithPlan() { + PlanNotebook notebook = PlanNotebook.builder().build(); + notebook.createPlanWithSubTasks( + "Anchor Plan", + "Desc", + "Outcome", + List.of(new SubTask("Task 1", "D1", "O1"))) + .block(); + notebook.saveAnchor(); + + // Replace with new plan + notebook.createPlanWithSubTasks( + "New Plan", + "New Desc", + "New Outcome", + List.of( + new SubTask("Task A", "DA", "OA"), + new SubTask("Task B", "DB", "OB"))) + .block(); + assertEquals("New Plan", notebook.getCurrentPlan().getName()); + + notebook.restoreAnchor(); + assertEquals("Anchor Plan", notebook.getCurrentPlan().getName()); + assertEquals(1, notebook.getCurrentPlan().getSubtasks().size()); + } + + @Test + @DisplayName("restoreAnchor() is a no-op when no anchor has been saved") + void testRestoreAnchorNoOpWhenNoAnchor() { + PlanNotebook notebook = PlanNotebook.builder().build(); + notebook.createPlanWithSubTasks( + "Plan", "Desc", "Outcome", List.of(new SubTask("T", "D", "O"))) + .block(); + // No saveAnchor called + notebook.restoreAnchor(); + // Plan should still be there + assertNotNull(notebook.getCurrentPlan()); + assertEquals("Plan", notebook.getCurrentPlan().getName()); + } + } + + // ==================== Anchor Persistence Tests ==================== + + @Nested + @DisplayName("Anchor persistence via saveTo/loadFrom") + class AnchorPersistenceTests { + + @Test + @DisplayName("saveTo persists anchor; loadFrom restores hasAnchor=true and anchorPlan") + void testAnchorPersistedAndLoadedWithPlan() { + PlanNotebook notebook = PlanNotebook.builder().build(); + notebook.createPlanWithSubTasks( + "Current Plan", + "Desc", + "Outcome", + List.of(new SubTask("T1", "D1", "O1"))) + .block(); + notebook.saveAnchor(); // anchor = Current Plan + + // Advance to new plan + notebook.createPlanWithSubTasks( + "Advanced Plan", + "Desc", + "Outcome", + List.of(new SubTask("T2", "D2", "O2"))) + .block(); + + notebook.saveTo(session, sessionKey); + + // Load into a fresh notebook + PlanNotebook loaded = PlanNotebook.builder().build(); + loaded.loadFrom(session, sessionKey); + + assertTrue(loaded.hasAnchor()); + assertEquals("Advanced Plan", loaded.getCurrentPlan().getName()); + + loaded.restoreAnchor(); + assertEquals("Current Plan", loaded.getCurrentPlan().getName()); + } + + @Test + @DisplayName( + "saveTo without saveAnchor does not persist anchor; loadFrom has hasAnchor=false") + void testNoAnchorNotPersisted() { + PlanNotebook notebook = PlanNotebook.builder().build(); + notebook.createPlanWithSubTasks( + "Plan", "Desc", "Outcome", List.of(new SubTask("T", "D", "O"))) + .block(); + // No saveAnchor + notebook.saveTo(session, sessionKey); + + PlanNotebook loaded = PlanNotebook.builder().build(); + loaded.loadFrom(session, sessionKey); + + assertFalse(loaded.hasAnchor()); + } + + @Test + @DisplayName("Anchor with custom keyPrefix uses correct storage key") + void testAnchorWithCustomKeyPrefix() { + PlanNotebook notebook = PlanNotebook.builder().keyPrefix("myPlan").build(); + notebook.createPlanWithSubTasks( + "Custom Prefix Plan", + "Desc", + "Outcome", + List.of(new SubTask("T", "D", "O"))) + .block(); + notebook.saveAnchor(); + notebook.saveTo(session, sessionKey); + + // Load with same prefix + PlanNotebook loaded = PlanNotebook.builder().keyPrefix("myPlan").build(); + loaded.loadFrom(session, sessionKey); + assertTrue(loaded.hasAnchor()); + + // Load with different prefix — no anchor + PlanNotebook otherLoaded = PlanNotebook.builder().keyPrefix("otherPlan").build(); + otherLoaded.loadFrom(session, sessionKey); + assertFalse(otherLoaded.hasAnchor()); + } + + @Test + @DisplayName("loadFrom clears existing in-memory anchor before loading from session") + void testLoadFromClearsInMemoryAnchor() { + // Set up: save a notebook with anchor + PlanNotebook notebook = PlanNotebook.builder().build(); + notebook.createPlanWithSubTasks( + "Plan A", "Desc", "Outcome", List.of(new SubTask("T", "D", "O"))) + .block(); + notebook.saveAnchor(); + notebook.saveTo(session, sessionKey); + + // Create a second notebook that already has a local anchor, then loadFrom + PlanNotebook loaded = PlanNotebook.builder().build(); + loaded.createPlanWithSubTasks( + "Local Plan", "Desc", "Outcome", List.of(new SubTask("LT", "LD", "LO"))) + .block(); + // Simulate a pre-existing local anchor (no anchor in session for this plan) + // Use a separate session key that has no anchor stored + SessionKey otherKey = SimpleSessionKey.of("other_session"); + + // Save without anchor to otherKey + PlanNotebook cleanNotebook = PlanNotebook.builder().build(); + cleanNotebook + .createPlanWithSubTasks( + "Clean Plan", "Desc", "Outcome", List.of(new SubTask("CT", "CD", "CO"))) + .block(); + cleanNotebook.saveTo(session, otherKey); + + // loaded has local state; after loadFrom(otherKey), anchor should be cleared + loaded.saveAnchor(); // give it a local anchor + assertTrue(loaded.hasAnchor()); + loaded.loadFrom(session, otherKey); + assertFalse(loaded.hasAnchor()); // local anchor should be overwritten to false + } + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxTest.java index cc97eb46d..c7dcdef5d 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxTest.java @@ -28,6 +28,8 @@ import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.session.InMemorySession; +import io.agentscope.core.state.SimpleSessionKey; import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.Tool; import io.agentscope.core.tool.ToolCallParam; @@ -1069,6 +1071,185 @@ void testCodeExecutionSectionOrderInPrompt() { } } + @Nested + @DisplayName("SkillBox Anchor Tests") + class SkillBoxAnchorTest { + + @Test + @DisplayName("hasAnchor should return false before saveAnchor is called") + void testHasAnchorReturnsFalseInitially() { + assertFalse(skillBox.hasAnchor(), "hasAnchor should be false before saveAnchor"); + } + + @Test + @DisplayName("saveAnchor should capture current skill activation states") + void testSaveAnchorCapturesSkillStates() { + AgentSkill skill1 = new AgentSkill("anchor_skill_1", "Skill 1", "# Content", null); + AgentSkill skill2 = new AgentSkill("anchor_skill_2", "Skill 2", "# Content", null); + skillBox.registerSkill(skill1); + skillBox.registerSkill(skill2); + + // saveAnchor should succeed without throwing + assertDoesNotThrow(() -> skillBox.saveAnchor()); + assertTrue(skillBox.hasAnchor(), "hasAnchor should be true after saveAnchor"); + } + + @Test + @DisplayName("restoreAnchor should restore skill activation states") + void testRestoreAnchorRestoresSkillStates() { + skillBox.registerSkillLoadTool(); + AgentSkill skill = new AgentSkill("anchor_skill", "Anchor Skill", "# Content", null); + AgentTool tool = createTestTool("anchor_tool"); + skillBox.registration().skill(skill).agentTool(tool).apply(); + + // Activate skill via load tool + Map loadInput = + Map.of("skillId", skill.getSkillId(), "path", "SKILL.md"); + ToolUseBlock loadCall = + ToolUseBlock.builder() + .id("anchor-load") + .name("load_skill_through_path") + .input(loadInput) + .content( + "{\"skillId\":\"" + + skill.getSkillId() + + "\",\"path\":\"SKILL.md\"}") + .build(); + toolkit.callTool( + ToolCallParam.builder().toolUseBlock(loadCall).input(loadInput).build()) + .block(); + + assertTrue(skillBox.isSkillActive(skill.getSkillId()), "Skill should be active"); + + // Save anchor while skill is active + skillBox.saveAnchor(); + + // Deactivate the skill + skillBox.deactivateAllSkills(); + skillBox.syncToolGroupStates(); + assertFalse( + skillBox.isSkillActive(skill.getSkillId()), + "Skill should be inactive after deactivation"); + + // Restore from anchor - skill should be active again + skillBox.restoreAnchor(); + assertTrue( + skillBox.isSkillActive(skill.getSkillId()), + "Skill should be active again after restoreAnchor"); + } + + @Test + @DisplayName("restoreAnchor should do nothing when no anchor exists") + void testRestoreAnchorNoOpWhenNoAnchor() { + AgentSkill skill = new AgentSkill("no_anchor_skill", "No Anchor Skill", "# C", null); + skillBox.registerSkill(skill); + + // restoreAnchor with no anchor should not throw and not change state + assertDoesNotThrow(() -> skillBox.restoreAnchor()); + assertFalse(skillBox.isSkillActive(skill.getSkillId())); + } + + @Test + @DisplayName("hasAnchor changes from false to true after saveAnchor") + void testHasAnchorTransition() { + AgentSkill skill = new AgentSkill("has_anchor_skill", "HA Skill", "# C", null); + skillBox.registerSkill(skill); + + assertFalse(skillBox.hasAnchor()); + skillBox.saveAnchor(); + assertTrue(skillBox.hasAnchor()); + } + + @Test + @DisplayName("saveTo should persist anchor state under skillbox_state_anchor key") + void testSaveToIncludesAnchorState() { + AgentSkill skill = new AgentSkill("persist_skill", "Persist Skill", "# C", null); + skillBox.registerSkill(skill); + + skillBox.saveAnchor(); + + InMemorySession session = new InMemorySession(); + SimpleSessionKey key = SimpleSessionKey.of("test-anchor-session"); + skillBox.saveTo(session, key); + + // Anchor state should be persisted + assertTrue( + session.get( + key, + "skillbox_state_anchor", + io.agentscope.core.state.SkillBoxState.class) + .isPresent(), + "skillbox_state_anchor should be saved in session"); + } + + @Test + @DisplayName("saveTo should not persist anchor state when no anchor exists") + void testSaveToDoesNotIncludeAnchorWhenNone() { + AgentSkill skill = new AgentSkill("no_persist_skill", "No Persist", "# C", null); + skillBox.registerSkill(skill); + + InMemorySession session = new InMemorySession(); + SimpleSessionKey key = SimpleSessionKey.of("test-no-anchor-session"); + skillBox.saveTo(session, key); + + // Anchor state should NOT be persisted when saveAnchor was never called + assertFalse( + session.get( + key, + "skillbox_state_anchor", + io.agentscope.core.state.SkillBoxState.class) + .isPresent(), + "skillbox_state_anchor should not be saved when no anchor exists"); + } + + @Test + @DisplayName("loadFrom should restore anchor from session") + void testLoadFromRestoresAnchorState() { + AgentSkill skill = new AgentSkill("load_anchor_skill", "Load Anchor", "# C", null); + skillBox.registerSkill(skill); + + // Save state with anchor + skillBox.saveAnchor(); + InMemorySession session = new InMemorySession(); + SimpleSessionKey key = SimpleSessionKey.of("test-load-anchor"); + skillBox.saveTo(session, key); + + // Create a new skillBox and load from session + Toolkit newToolkit = new Toolkit(); + SkillBox newSkillBox = new SkillBox(newToolkit); + newToolkit.registerTool(newSkillBox); + newSkillBox.registerSkill(skill); + + assertFalse(newSkillBox.hasAnchor(), "New skillBox should have no anchor"); + newSkillBox.loadFrom(session, key); + assertTrue(newSkillBox.hasAnchor(), "Anchor should be restored after loadFrom"); + } + + @Test + @DisplayName("loadFrom should clear anchor when session has no anchor data") + void testLoadFromClearsAnchorWhenNotInSession() { + AgentSkill skill = new AgentSkill("clear_anchor_skill", "Clear Anchor", "# C", null); + skillBox.registerSkill(skill); + + // Set an anchor + skillBox.saveAnchor(); + assertTrue(skillBox.hasAnchor()); + + // Save state WITHOUT anchor (use a new skillBox that has no anchor) + Toolkit otherToolkit = new Toolkit(); + SkillBox otherBox = new SkillBox(otherToolkit); + otherBox.registerSkill(skill); + InMemorySession session = new InMemorySession(); + SimpleSessionKey key = SimpleSessionKey.of("no-anchor-key"); + otherBox.saveTo(session, key); + + // Load from session that has no anchor - should clear existing anchor + skillBox.loadFrom(session, key); + assertFalse( + skillBox.hasAnchor(), "Anchor should be cleared after loadFrom with no anchor"); + } + } + /** * Test tool class with @Tool annotated methods for testing tool object * registration. diff --git a/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java b/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java index 2fc5d8c15..ae0142479 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java @@ -15,10 +15,13 @@ */ package io.agentscope.core.state; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.session.InMemorySession; +import io.agentscope.core.session.Session; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -187,4 +190,77 @@ void testConstructor() { assertTrue(persistence.skillBoxManaged()); } } + + @Nested + @DisplayName("StateModule Default Anchor Methods") + class StateModuleDefaultAnchorTests { + + /** A minimal implementation of StateModule for testing default methods. */ + private final StateModule defaultImpl = + new StateModule() { + @Override + public void saveTo(Session session, SessionKey sessionKey) { + // no-op + } + + @Override + public void loadFrom(Session session, SessionKey sessionKey) { + // no-op + } + }; + + @Test + @DisplayName("saveAnchor default implementation should not throw") + void testSaveAnchorIsNoOp() { + assertDoesNotThrow( + () -> defaultImpl.saveAnchor(), + "Default saveAnchor should not throw any exception"); + } + + @Test + @DisplayName("restoreAnchor default implementation should not throw") + void testRestoreAnchorIsNoOp() { + assertDoesNotThrow( + () -> defaultImpl.restoreAnchor(), + "Default restoreAnchor should not throw any exception"); + } + + @Test + @DisplayName("hasAnchor default implementation should return false") + void testHasAnchorReturnsFalse() { + assertFalse(defaultImpl.hasAnchor(), "Default hasAnchor should return false"); + } + + @Test + @DisplayName("saveAnchor followed by restoreAnchor should not throw") + void testSaveAnchorThenRestoreAnchor() { + assertDoesNotThrow( + () -> { + defaultImpl.saveAnchor(); + defaultImpl.restoreAnchor(); + }, + "Calling saveAnchor then restoreAnchor should not throw"); + } + + @Test + @DisplayName("hasAnchor should still return false after default saveAnchor") + void testHasAnchorAfterDefaultSaveAnchor() { + defaultImpl.saveAnchor(); + assertFalse( + defaultImpl.hasAnchor(), + "Default hasAnchor should still return false even after saveAnchor"); + } + + @Test + @DisplayName("saveTo and loadFrom string overloads should not throw") + void testStringOverloadsAreNoOp() { + InMemorySession session = new InMemorySession(); + assertDoesNotThrow( + () -> defaultImpl.saveTo(session, "test-session"), + "saveTo with string ID should not throw"); + assertDoesNotThrow( + () -> defaultImpl.loadFrom(session, "test-session"), + "loadFrom with string ID should not throw"); + } + } } diff --git a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java index 15d7fa930..fdff95386 100644 --- a/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java +++ b/agentscope-extensions/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java @@ -44,6 +44,7 @@ import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -2073,4 +2074,143 @@ void testToolCompressionCursorAdvancesWhenSkipped() { testModel.getCallCount(), "Model should be called exactly once for the second high-token tool group"); } + + @Nested + @DisplayName("deleteMessagesFrom Tests") + class DeleteMessagesFromTests { + + private AutoContextMemory testMemory; + + @BeforeEach + void setUpDeleteMemory() { + AutoContextConfig cfg = + AutoContextConfig.builder() + .msgThreshold(100) + .maxToken(100000) + .tokenRatio(0.9) + .lastKeep(5) + .build(); + testMemory = new AutoContextMemory(cfg, new TestModel("summary")); + } + + @Test + @DisplayName("Should truncate from middle index in both working and original storage") + void testDeleteFromMiddleIndex() { + // Add 5 messages + for (int i = 0; i < 5; i++) { + testMemory.addMessage(createTextMessage("Msg " + i, MsgRole.USER)); + } + + // Delete from index 2 (keep first 2 messages: index 0 and 1) + testMemory.deleteMessagesFrom(2); + + List working = testMemory.getMessages(); + assertEquals(2, working.size(), "Working storage should have 2 messages"); + assertEquals("Msg 0", working.get(0).getTextContent()); + assertEquals("Msg 1", working.get(1).getTextContent()); + + List original = testMemory.getOriginalMemoryMsgs(); + assertEquals(2, original.size(), "Original storage should also have 2 messages"); + } + + @Test + @DisplayName("fromIndex=0 should clear all messages from both storages") + void testDeleteFromIndexZeroClearsAll() { + for (int i = 0; i < 4; i++) { + testMemory.addMessage(createTextMessage("Msg " + i, MsgRole.USER)); + } + + testMemory.deleteMessagesFrom(0); + + assertTrue(testMemory.getMessages().isEmpty(), "Working storage should be empty"); + assertTrue( + testMemory.getOriginalMemoryMsgs().isEmpty(), + "Original storage should be empty"); + } + + @Test + @DisplayName("Negative fromIndex should be ignored (no-op)") + void testDeleteFromNegativeIndexIsNoOp() { + for (int i = 0; i < 3; i++) { + testMemory.addMessage(createTextMessage("Msg " + i, MsgRole.USER)); + } + + testMemory.deleteMessagesFrom(-1); + + assertEquals(3, testMemory.getMessages().size(), "Should remain unchanged"); + assertEquals(3, testMemory.getOriginalMemoryMsgs().size(), "Should remain unchanged"); + } + + @Test + @DisplayName("fromIndex equal to size should be ignored (no-op)") + void testDeleteFromIndexEqualToSizeIsNoOp() { + for (int i = 0; i < 3; i++) { + testMemory.addMessage(createTextMessage("Msg " + i, MsgRole.USER)); + } + int size = testMemory.getMessages().size(); // 3 + + testMemory.deleteMessagesFrom(size); + + assertEquals(3, testMemory.getMessages().size(), "Should remain unchanged"); + } + + @Test + @DisplayName("fromIndex greater than size should be ignored (no-op)") + void testDeleteFromIndexGreaterThanSizeIsNoOp() { + for (int i = 0; i < 3; i++) { + testMemory.addMessage(createTextMessage("Msg " + i, MsgRole.USER)); + } + + testMemory.deleteMessagesFrom(10); + + assertEquals(3, testMemory.getMessages().size(), "Should remain unchanged"); + } + + @Test + @DisplayName("Both working and original storage should be consistent after delete") + void testDualStorageConsistencyAfterDelete() { + // Add 6 messages with distinct content + for (int i = 0; i < 6; i++) { + testMemory.addMessage(createTextMessage("Message " + i, MsgRole.USER)); + } + + // Delete from index 3 + testMemory.deleteMessagesFrom(3); + + List working = testMemory.getMessages(); + List original = testMemory.getOriginalMemoryMsgs(); + + assertEquals(3, working.size(), "Working storage should have 3 messages"); + assertEquals(3, original.size(), "Original storage should have 3 messages"); + + // Verify the remaining messages are the same in both storages + for (int i = 0; i < 3; i++) { + assertEquals( + working.get(i).getId(), + original.get(i).getId(), + "Message IDs should match between storages at index " + i); + assertEquals( + "Message " + i, + working.get(i).getTextContent(), + "Working message content should match at index " + i); + assertEquals( + "Message " + i, + original.get(i).getTextContent(), + "Original message content should match at index " + i); + } + } + + @Test + @DisplayName("Should delete last message when fromIndex is size-1") + void testDeleteLastMessage() { + for (int i = 0; i < 4; i++) { + testMemory.addMessage(createTextMessage("Msg " + i, MsgRole.USER)); + } + + testMemory.deleteMessagesFrom(3); + + assertEquals(3, testMemory.getMessages().size(), "Should keep first 3 messages"); + assertEquals("Msg 2", testMemory.getMessages().get(2).getTextContent()); + } + } }