Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -206,16 +206,23 @@ public boolean compressIfNeeded() {
int toolIters = 5;
boolean toolCompressed = false;
int compressionCount = 0;
int cursorStartIndex = 0;
while (toolIters > 0) {
toolIters--;
List<Msg> currentMsgs = new ArrayList<>(workingMemoryStorage);
Pair<Integer, Integer> toolMsgIndices =
extractPrevToolMsgsForCompress(currentMsgs, autoContextConfig.getLastKeep());
extractPrevToolMsgsForCompress(
currentMsgs, autoContextConfig.getLastKeep(), cursorStartIndex);
if (toolMsgIndices != null) {
summaryToolsMessages(currentMsgs, toolMsgIndices);
replaceWorkingMessage(currentMsgs);
toolCompressed = true;
compressionCount++;
boolean actuallyCompressed = summaryToolsMessages(currentMsgs, toolMsgIndices);
if (actuallyCompressed) {
replaceWorkingMessage(currentMsgs);
toolCompressed = true;
compressionCount++;
cursorStartIndex = toolMsgIndices.first() + 1;
} else {
cursorStartIndex = toolMsgIndices.second() + 1;
}
} else {
break;
}
Expand All @@ -225,7 +232,9 @@ public boolean compressIfNeeded() {
"Strategy 1: APPLIED - Compressed {} tool invocation groups", compressionCount);
return true;
} else {
log.info("Strategy 1: SKIPPED - No compressible tool invocations found");
log.info(
"Strategy 1: SKIPPED - No compressible tool invocations found (or skipped due"
+ " to low tokens)");
}

// Strategy 2: Offload previous round large messages (with lastKeep protection)
Expand Down Expand Up @@ -803,9 +812,10 @@ private Msg generateCurrentRoundSummaryFromMessages(List<Msg> messages, String o
* Summarize current round of conversation messages.
*
* @param rawMessages the list of messages to process
* @param toolMsgIndices the pair of start and end indices
* @return true if summary was actually performed, false otherwise
*/
private void summaryToolsMessages(
private boolean summaryToolsMessages(
List<Msg> rawMessages, Pair<Integer, Integer> toolMsgIndices) {
int startIndex = toolMsgIndices.first();
int endIndex = toolMsgIndices.second();
Expand All @@ -831,7 +841,7 @@ private void summaryToolsMessages(
+ " ({})",
originalTokens,
threshold);
return;
return false;
}

log.info(
Expand Down Expand Up @@ -863,6 +873,8 @@ private void summaryToolsMessages(
metadata);

MsgUtils.replaceMsg(rawMessages, startIndex, endIndex, toolsSummary);

return true;
}

/**
Expand Down Expand Up @@ -1299,22 +1311,27 @@ public void deleteMessage(int index) {
* Extract tool messages from raw messages for compression.
*
* <p>This method finds consecutive tool invocation messages in historical conversations
* that can be compressed. It searches for sequences of more than consecutive tool messages
* before the latest assistant message.
* that can be compressed. It searches, using a cursor-based {@code searchStartIndex},
* for sequences of more than a minimum number of consecutive tool messages that appear
* before the latest assistant message that should be preserved.
*
* <p>Strategy:
* 1. If rawMessages has less than lastKeep messages, return null
* 2. Find the latest assistant message and protect it and all messages after it
* 3. Search from the beginning for the oldest consecutive tool messages (more than minConsecutiveToolMessages consecutive)
* that can be compressed
* 4. If no assistant message is found, protect the last N messages (lastKeep)
* 1. If {@code rawMessages} has less than {@code lastKeep} messages, return {@code null}.
* 2. Identify the latest assistant message and treat it and all messages after it as
* protected content that will not be compressed.
* 3. Starting from {@code searchStartIndex}, search for the oldest range of consecutive
* tool messages (more than {@code minConsecutiveToolMessages} consecutive) that lies
* entirely before the protected region and can be compressed.
* 4. If no eligible assistant message or compressible tool-message sequence is found
* in the searchable range, return {@code null}.
*
* @param rawMessages all raw messages
* @param lastKeep number of recent messages to keep uncompressed
* @return Pair containing startIndex and endIndex (inclusive) of compressible tool messages, or null if none found
* @param searchStartIndex the index to start searching from (used as a cursor)
* @return Pair containing startIndex and endIndex (inclusive) of compressible tool messages, or {@code null} if none found
*/
private Pair<Integer, Integer> extractPrevToolMsgsForCompress(
List<Msg> rawMessages, int lastKeep) {
List<Msg> rawMessages, int lastKeep, int searchStartIndex) {
if (rawMessages == null || rawMessages.isEmpty()) {
return null;
}
Expand Down Expand Up @@ -1348,8 +1365,8 @@ private Pair<Integer, Integer> extractPrevToolMsgsForCompress(
int consecutiveCount = 0;
int startIndex = -1;
int endIndex = -1;

for (int i = 0; i < searchEndIndex; i++) {
int actualStart = Math.max(0, searchStartIndex);
for (int i = actualStart; i < searchEndIndex; i++) {
Msg msg = rawMessages.get(i);
if (MsgUtils.isToolMessage(msg)) {
if (consecutiveCount == 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ void testToolMessageCompression() {
.msgThreshold(10)
.minConsecutiveToolMessages(3)
.lastKeep(5)
.minCompressionTokenThreshold(0)
.build();
AutoContextMemory toolMemory = new AutoContextMemory(toolConfig, toolTestModel);

Expand Down Expand Up @@ -1204,6 +1205,7 @@ void testDefaultPrompts() {
.msgThreshold(10)
.minConsecutiveToolMessages(3)
.lastKeep(5)
.minCompressionTokenThreshold(0)
.build();
CapturingModel capturingModel = new CapturingModel("Compressed tool summary");
AutoContextMemory memory = new AutoContextMemory(config, capturingModel);
Expand Down Expand Up @@ -1265,6 +1267,7 @@ void testCustomPrompt() {
.msgThreshold(10)
.minConsecutiveToolMessages(3)
.lastKeep(5)
.minCompressionTokenThreshold(0)
.customPrompt(customPrompt)
.build();
CapturingModel capturingModel = new CapturingModel("Compressed tool summary");
Expand Down Expand Up @@ -1369,6 +1372,7 @@ void testMixedCustomAndDefaultPrompts() {
.msgThreshold(10)
.minConsecutiveToolMessages(3)
.lastKeep(5)
.minCompressionTokenThreshold(0)
.customPrompt(customPrompt)
.build();
CapturingModel capturingModel = new CapturingModel("Compressed");
Expand Down Expand Up @@ -1677,4 +1681,136 @@ void testGetPlanStateContextWithDifferentPlanStates() throws Exception {
resultDone.contains("Goal: Test Description"),
"Should contain goal for DONE state");
}

@Test
@DisplayName(
"Should continue to subsequent strategies when tool compression is skipped due to low"
+ " tokens")
void testCompressionStrategiesContinueWhenToolCompressionSkipped() {
TestModel testModel = new TestModel("Large payload summary");
AutoContextConfig config =
AutoContextConfig.builder()
.msgThreshold(5)
.minConsecutiveToolMessages(2)
.largePayloadThreshold(100)
.lastKeep(2)
.minCompressionTokenThreshold(10000)
.build();
AutoContextMemory testMemory = new AutoContextMemory(config, testModel);

testMemory.addMessage(createTextMessage("User query", MsgRole.USER));
for (int i = 0; i < 3; i++) {
testMemory.addMessage(createToolUseMessage("skipped_tool", "id" + i));
testMemory.addMessage(createToolResultMessage("skipped_tool", "id" + i, "ok"));
}

// Add a large message to trigger Strategy 2 or 3
String largeText = "x".repeat(200);
testMemory.addMessage(createTextMessage(largeText, MsgRole.USER));
testMemory.addMessage(createTextMessage("Assistant response", MsgRole.ASSISTANT));
testMemory.addMessage(createTextMessage("Padding message", MsgRole.USER));

boolean compressed = testMemory.compressIfNeeded();
assertTrue(
compressed,
"Compression should return true because subsequent strategy (large payload) was"
+ " applied");

long toolMessageCount =
testMemory.getMessages().stream().filter(MsgUtils::isToolMessage).count();
assertEquals(
6, toolMessageCount, "Tool messages should not be compressed due to low tokens");

boolean hasOffloadedLargeMsg =
testMemory.getMessages().stream()
.anyMatch(
msg ->
msg.getTextContent() != null
&& msg.getTextContent()
.contains("CONTEXT_OFFLOAD"));
assertTrue(
hasOffloadedLargeMsg,
"Large message should be offloaded by Strategy 2/3 because the chain was not"
+ " broken");
}

@Test
@DisplayName(
"Should advance search cursor and compress subsequent tool groups when earlier group is"
+ " skipped")
void testToolCompressionCursorAdvancesWhenSkipped() {
TestModel testModel = new TestModel("Compressed tool summary");
AutoContextConfig config =
AutoContextConfig.builder()
.msgThreshold(5)
.minConsecutiveToolMessages(2)
.lastKeep(2)
.minCompressionTokenThreshold(5000)
.build();
AutoContextMemory testMemory = new AutoContextMemory(config, testModel);

testMemory.addMessage(createTextMessage("User query 1", MsgRole.USER));
for (int i = 0; i < 3; i++) {
testMemory.addMessage(createToolUseMessage("short_tool", "a" + i));
testMemory.addMessage(createToolResultMessage("short_tool", "a" + i, "ok"));
}

testMemory.addMessage(createTextMessage("User query 2", MsgRole.USER));

for (int i = 0; i < 3; i++) {
testMemory.addMessage(createToolUseMessage("long_tool", "b" + i));
String largeResult = "long_result_".repeat(1000);
testMemory.addMessage(createToolResultMessage("long_tool", "b" + i, largeResult));
}

testMemory.addMessage(createTextMessage("Assistant response", MsgRole.ASSISTANT));

testMemory.addMessage(createTextMessage("Padding 1", MsgRole.USER));
testMemory.addMessage(createTextMessage("Padding 2", MsgRole.USER));

// Trigger compression explicitly
testMemory.compressIfNeeded();

List<Msg> messages = testMemory.getMessages();

// The filter condition only captured Tool Result (name="short_tool").
// So 3 results indicate that all 6 messages in the first group were preserved.
long shortToolMsgs =
messages.stream()
.filter(
msg ->
MsgUtils.isToolMessage(msg)
&& "short_tool".equals(msg.getName()))
.count();
assertEquals(
3,
shortToolMsgs,
"First tool group should be skipped and remain in memory (3 result messages)");

long longToolMsgs =
messages.stream()
.filter(
msg ->
MsgUtils.isToolMessage(msg)
&& "long_tool".equals(msg.getName()))
.count();
assertEquals(
0,
longToolMsgs,
"Second tool group should be completely compressed and removed from memory");

boolean hasSummary =
messages.stream()
.anyMatch(
msg ->
msg.getTextContent() != null
&& msg.getTextContent()
.contains("Compressed tool summary"));
assertTrue(hasSummary, "Second tool group should be replaced by a summary message");

assertEquals(
1,
testModel.getCallCount(),
"Model should be called exactly once for the second high-token tool group");
}
}
Loading