This method handles tool message conversion internally:
+ * - Converts tool messages to text for compression context (to avoid model errors)
+ * - Preserves original message structure in the result (ToolUseBlock/ToolResultBlock)
+ *
* @param message the message to summarize
* @param offloadUuid the UUID of offloaded message
* @return a summary message preserving the original role and name
@@ -585,7 +598,11 @@ private Msg generateLargeMessageSummary(Msg message, String offloadUuid) {
customPrompt))
.build())
.build());
- newMessages.add(message);
+
+ // Convert tool-related messages to text format to avoid triggering tool call/tool result
+ // detection in the compression context, which would cause model errors
+ Msg messageForCompression = convertToolMessageToText(message);
+ newMessages.add(messageForCompression);
newMessages.add(
Msg.builder()
.role(MsgRole.USER)
@@ -626,18 +643,141 @@ private Msg generateLargeMessageSummary(Msg message, String offloadUuid) {
metadata.put(MessageMetadataKeys.CHAT_USAGE, block.getChatUsage());
}
- // Create summary message preserving original role and name
+ // Create summary message
+ // If original message was a tool message, preserve its structure
String summaryContent = block != null ? block.getTextContent() : "";
String finalContent = summaryContent;
if (!offloadHint.isEmpty()) {
finalContent = summaryContent + "\n" + offloadHint;
}
+ // Check if original message was a tool message
+ if (message.hasContentBlocks(ToolUseBlock.class)) {
+ // Preserve ToolUseBlock structure with compressed summary
+ List This is necessary because when large messages are placed in the compression context,
+ * tool call/tool result structures may trigger the model's tool detection mechanism,
+ * causing errors. Converting them to plain text avoids this issue.
+ *
+ * @param message the message to convert
+ * @return a text-formatted message if the original was a tool message, otherwise the original
+ * message unchanged
+ */
+ private Msg convertToolMessageToText(Msg message) {
+ if (message == null) {
+ return message;
+ }
+
+ // Only convert tool-related messages
+ if (!MsgUtils.isToolMessage(message)) {
+ return message;
+ }
+
+ StringBuilder textContent = new StringBuilder();
+
+ // Handle ToolUseBlock messages
+ if (message.hasContentBlocks(ToolUseBlock.class)) {
+ textContent.append("[Tool Call]\n");
+ for (var block : message.getContent()) {
+ if (block instanceof ToolUseBlock) {
+ ToolUseBlock toolUse = (ToolUseBlock) block;
+ textContent.append("Tool: ").append(toolUse.getName()).append("\n");
+ if (toolUse.getId() != null) {
+ textContent.append("ID: ").append(toolUse.getId()).append("\n");
+ }
+ if (toolUse.getContent() != null && !toolUse.getContent().isEmpty()) {
+ textContent.append("Arguments: ").append(toolUse.getContent()).append("\n");
+ }
+ }
+ }
+ }
+
+ // Handle ToolResultBlock messages
+ if (message.hasContentBlocks(ToolResultBlock.class) || message.getRole() == MsgRole.TOOL) {
+ textContent.append("[Tool Result]\n");
+ for (var block : message.getContent()) {
+ if (block instanceof ToolResultBlock) {
+ ToolResultBlock toolResult = (ToolResultBlock) block;
+ textContent.append("Tool: ").append(toolResult.getName()).append("\n");
+ if (toolResult.getId() != null) {
+ textContent.append("ID: ").append(toolResult.getId()).append("\n");
+ }
+ // Extract text content from output
+ if (toolResult.getOutput() != null) {
+ for (var outputBlock : toolResult.getOutput()) {
+ if (outputBlock instanceof TextBlock) {
+ String text = ((TextBlock) outputBlock).getText();
+ if (text != null) {
+ textContent.append("Output: ").append(text).append("\n");
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // If no content was extracted, use a fallback message
+ if (textContent.length() == 0) {
+ textContent.append("[Tool message with no extractable content]");
+ }
+
+ // Return as ASSISTANT role with text content to avoid tool detection
return Msg.builder()
- .role(message.getRole())
+ .role(MsgRole.ASSISTANT)
.name(message.getName())
- .content(TextBlock.builder().text(finalContent).build())
- .metadata(metadata)
+ .content(TextBlock.builder().text(textContent.toString().trim()).build())
+ .metadata(message.getMetadata())
.build();
}
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..400d1f35f 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
@@ -1039,6 +1039,50 @@ void reset() {
}
}
+ /**
+ * TestModel that captures messages sent to it for verification.
+ */
+ private static class CapturingTestModel implements Model {
+ private final String responseText;
+ private int callCount = 0;
+ private final List> capturedMessages = new ArrayList<>();
+
+ CapturingTestModel(String responseText) {
+ this.responseText = responseText;
+ }
+
+ @Override
+ public Flux
> getCapturedMessages() {
+ return new ArrayList<>(capturedMessages);
+ }
+
+ void reset() {
+ callCount = 0;
+ capturedMessages.clear();
+ }
+ }
+
// ==================== PlanNotebook Integration Tests ====================
@Test
@@ -1994,6 +2038,87 @@ void testCompressionStrategiesContinueWhenToolCompressionSkipped() {
+ " broken");
}
+ @Test
+ @DisplayName(
+ "Should convert large tool messages to text format during compression to avoid"
+ + " model errors")
+ void testLargeToolMessageConvertedToTextDuringCompression() throws Exception {
+ // Create a TestModel that can capture the messages sent to it
+ CapturingTestModel capturingModel = new CapturingTestModel("Summary");
+ AutoContextConfig config =
+ AutoContextConfig.builder()
+ .msgThreshold(10)
+ .largePayloadThreshold(100) // low threshold to trigger compression
+ .lastKeep(5)
+ .minConsecutiveToolMessages(100) // disable Strategy 1
+ .minCompressionTokenThreshold(Integer.MAX_VALUE) // disable LLM compression
+ .build();
+ AutoContextMemory testMemory = new AutoContextMemory(config, capturingModel);
+
+ // Add messages to exceed msgThreshold
+ for (int i = 0; i < 8; i++) {
+ testMemory.addMessage(createTextMessage("Message " + i, MsgRole.USER));
+ }
+
+ // Add a user message (this becomes the latest user)
+ testMemory.addMessage(createTextMessage("User query", MsgRole.USER));
+
+ // Create a large tool result message that exceeds largePayloadThreshold
+ String largeOutput = "y".repeat(200);
+ Msg largeToolResultMsg =
+ Msg.builder()
+ .role(MsgRole.TOOL)
+ .name("search")
+ .content(
+ ToolResultBlock.builder()
+ .id("call_001")
+ .name("search")
+ .output(
+ List.of(
+ TextBlock.builder()
+ .text(largeOutput)
+ .build()))
+ .build())
+ .build();
+ testMemory.addMessage(largeToolResultMsg);
+
+ // Trigger compression - this should trigger Strategy 5
+ testMemory.compressIfNeeded();
+
+ // Verify that the model was called for compression
+ assertTrue(capturingModel.getCallCount() > 0, "Model should be called for compression");
+
+ // Check the messages sent to the model - tool messages should be converted to ASSISTANT
+ // role
+ List
> capturedMessagesList = capturingModel.getCapturedMessages();
+ assertFalse(capturedMessagesList.isEmpty(), "Should have captured messages sent to model");
+
+ // Verify that in compression context, TOOL role messages are converted to ASSISTANT
+ boolean foundConvertedToolMessage = false;
+ for (List