-
Notifications
You must be signed in to change notification settings - Fork 528
fix(autocontextmemory):large message compression format adaptation #1180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fc93d09
b0a0b5b
219528a
053a7aa
5a82b32
af716e1
72c28ba
136b5fa
b1b77ad
60f0e4c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| import io.agentscope.core.agent.accumulator.ReasoningContext; | ||||||||||||||||||||||||||||||||
| import io.agentscope.core.memory.Memory; | ||||||||||||||||||||||||||||||||
| import io.agentscope.core.message.ContentBlock; | ||||||||||||||||||||||||||||||||
| import io.agentscope.core.message.MessageMetadataKeys; | ||||||||||||||||||||||||||||||||
| import io.agentscope.core.message.Msg; | ||||||||||||||||||||||||||||||||
| import io.agentscope.core.message.MsgRole; | ||||||||||||||||||||||||||||||||
|
|
@@ -506,13 +507,21 @@ private boolean summaryCurrentRoundLargeMessages(List<Msg> rawMessages) { | |||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| String textContent = msg.getTextContent(); | ||||||||||||||||||||||||||||||||
| // Calculate message size using character count (handles all message types including | ||||||||||||||||||||||||||||||||
| // tool calls) | ||||||||||||||||||||||||||||||||
| int messageSize = MsgUtils.calculateMessageCharCount(msg); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Check if message content exceeds threshold | ||||||||||||||||||||||||||||||||
| if (textContent == null || textContent.length() <= threshold) { | ||||||||||||||||||||||||||||||||
| if (messageSize <= threshold) { | ||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| log.info( | ||||||||||||||||||||||||||||||||
| "Found large message at index {} with size {} chars (threshold: {})", | ||||||||||||||||||||||||||||||||
| i, | ||||||||||||||||||||||||||||||||
| messageSize, | ||||||||||||||||||||||||||||||||
| threshold); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Step 4: Offload the original message | ||||||||||||||||||||||||||||||||
| String uuid = UUID.randomUUID().toString(); | ||||||||||||||||||||||||||||||||
| List<Msg> offloadMsg = new ArrayList<>(); | ||||||||||||||||||||||||||||||||
|
|
@@ -521,7 +530,7 @@ private boolean summaryCurrentRoundLargeMessages(List<Msg> rawMessages) { | |||||||||||||||||||||||||||||||
| log.info( | ||||||||||||||||||||||||||||||||
| "Offloaded current round large message: index={}, size={} chars, uuid={}", | ||||||||||||||||||||||||||||||||
| i, | ||||||||||||||||||||||||||||||||
| textContent.length(), | ||||||||||||||||||||||||||||||||
| messageSize, | ||||||||||||||||||||||||||||||||
| uuid); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Step 5: Generate summary using LLM | ||||||||||||||||||||||||||||||||
|
|
@@ -560,6 +569,10 @@ private boolean summaryCurrentRoundLargeMessages(List<Msg> rawMessages) { | |||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||
| * Generate a summary of a large message using the model. | ||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||
| * <p>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<ContentBlock> compressedBlocks = new ArrayList<>(); | ||||||||||||||||||||||||||||||||
| for (ContentBlock originalBlock : message.getContent()) { | ||||||||||||||||||||||||||||||||
| if (originalBlock instanceof ToolUseBlock) { | ||||||||||||||||||||||||||||||||
| ToolUseBlock originalToolUse = (ToolUseBlock) originalBlock; | ||||||||||||||||||||||||||||||||
| // Keep original tool use block (tool calls are usually not too large) | ||||||||||||||||||||||||||||||||
|
Comment on lines
+656
to
+661
|
||||||||||||||||||||||||||||||||
| // Preserve ToolUseBlock structure with compressed summary | |
| List<ContentBlock> compressedBlocks = new ArrayList<>(); | |
| for (ContentBlock originalBlock : originalMessage.getContent()) { | |
| if (originalBlock instanceof ToolUseBlock) { | |
| ToolUseBlock originalToolUse = (ToolUseBlock) originalBlock; | |
| // Keep original tool use block (tool calls are usually not too large) | |
| // Preserve ToolUseBlock structure and include the summary/offload hint as text | |
| List<ContentBlock> compressedBlocks = new ArrayList<>(); | |
| if (!finalContent.isEmpty()) { | |
| compressedBlocks.add(TextBlock.builder().text(finalContent).build()); | |
| } | |
| for (ContentBlock originalBlock : originalMessage.getContent()) { | |
| if (originalBlock instanceof ToolUseBlock) { | |
| ToolUseBlock originalToolUse = (ToolUseBlock) originalBlock; | |
| // Keep original tool use block while surfacing the compressed summary to the model |
Copilot
AI
Apr 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When rebuilding a summarized ToolResult message, this loop only retains ToolResultBlock instances and drops any other ContentBlocks that may be present in the original message (e.g., TextBlocks before/after the tool result, which exist elsewhere in the codebase). This risks losing information during compression. Consider preserving non-ToolResult blocks (or at least appending them) while replacing only the ToolResultBlock.output with the compressed summary.
Copilot
AI
Apr 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The rebuilt ToolResultBlock does not preserve originalToolResult.getMetadata() (contrast with the offload path later in this class that explicitly keeps ToolResult metadata). This can drop semantic flags like agentscope_suspended and other provider metadata. Preserve the original ToolResultBlock metadata when constructing the compressed ToolResultBlock.
| // Replace output with compressed summary | |
| ToolResultBlock compressedToolResult = | |
| ToolResultBlock.builder() | |
| .id(originalToolResult.getId()) | |
| .name(originalToolResult.getName()) | |
| .output(List.of(TextBlock.builder().text(finalContent).build())) | |
| // Replace output with compressed summary while preserving tool result metadata | |
| ToolResultBlock compressedToolResult = | |
| ToolResultBlock.builder() | |
| .id(originalToolResult.getId()) | |
| .name(originalToolResult.getName()) | |
| .output(List.of(TextBlock.builder().text(finalContent).build())) | |
| .metadata(originalToolResult.getMetadata()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This drops the content and only retains the tool use.
Copilot
AI
Apr 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For ToolUseBlock conversion, this uses toolUse.getContent() (raw streaming content) to populate “Arguments”, which is often null/empty for normal tool calls. This means the converted text sent to the model can omit the actual tool inputs (toolUse.getInput()). Consider serializing getInput() (and optionally also getContent() when present) so compression has the arguments needed for an accurate summary.
| if (toolUse.getContent() != null && !toolUse.getContent().isEmpty()) { | |
| textContent.append("Arguments: ").append(toolUse.getContent()).append("\n"); | |
| } | |
| Object toolInput = toolUse.getInput(); | |
| String rawContent = toolUse.getContent(); | |
| if (toolInput != null) { | |
| textContent.append("Arguments: ").append(String.valueOf(toolInput)).append("\n"); | |
| } | |
| if (rawContent != null && !rawContent.isEmpty() | |
| && (toolInput == null || !rawContent.equals(String.valueOf(toolInput)))) { | |
| textContent.append("Raw Content: ").append(rawContent).append("\n"); | |
| } |
Copilot
AI
Apr 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ToolResult conversion only extracts output from TextBlock instances. Tool outputs can contain other ContentBlock types (e.g., images), which would be silently omitted from the compression context and can degrade summarization. Consider adding a fallback representation for non-TextBlock outputs (type name, URL/metadata, or toString()), or at least a placeholder indicating omitted non-text content.
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<List<Msg>> capturedMessages = new ArrayList<>(); | ||||||||
|
|
||||||||
| CapturingTestModel(String responseText) { | ||||||||
| this.responseText = responseText; | ||||||||
| } | ||||||||
|
|
||||||||
| @Override | ||||||||
| public Flux<ChatResponse> stream( | ||||||||
| List<Msg> messages, List<ToolSchema> tools, GenerateOptions options) { | ||||||||
| callCount++; | ||||||||
| capturedMessages.add(new ArrayList<>(messages)); | ||||||||
| ChatResponse response = | ||||||||
| ChatResponse.builder() | ||||||||
| .content(List.of(TextBlock.builder().text(responseText).build())) | ||||||||
| .usage(new ChatUsage(10, 20, 30)) | ||||||||
| .build(); | ||||||||
| return Flux.just(response); | ||||||||
| } | ||||||||
|
|
||||||||
| @Override | ||||||||
| public String getModelName() { | ||||||||
| return "capturing-test-model"; | ||||||||
| } | ||||||||
|
|
||||||||
| int getCallCount() { | ||||||||
| return callCount; | ||||||||
| } | ||||||||
|
|
||||||||
| List<List<Msg>> 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 | ||||||||
|
||||||||
| .minCompressionTokenThreshold(Integer.MAX_VALUE) // disable LLM compression | |
| .minCompressionTokenThreshold( | |
| Integer.MAX_VALUE) // disable Strategy 1 token-threshold gating only |
Copilot
AI
Apr 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test computes foundConvertedToolMessage but never asserts it, so the test can pass even if no tool message was converted (it only asserts callCount > 0, twice). Add an assertion that foundConvertedToolMessage is true (and remove the redundant second callCount assertion) to ensure the test actually validates the conversion behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Msg has already been processed by convertToolMessageToText in the outer method. No need for redundant processing.