From e953f8be5aa6fb7bc09ad148bb6fd81025fc391a Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 24 May 2026 23:00:11 +0800 Subject: [PATCH] feat(agent): age-based compaction of older tool-response bodies --- .../context/ConversationWindowManager.java | 96 +++++++++++ .../mate/agent/graph/node/ReasoningNode.java | 20 +++ .../context/CompactAgedToolResponsesTest.java | 158 ++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/context/CompactAgedToolResponsesTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java index 07c44e6b..26de0ac8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java @@ -904,6 +904,102 @@ public class ConversationWindowManager { && r.responseData().startsWith(ToolResultStorage.SPILL_MARKER_PREFIX); } + /** + * Age-based compaction. Replace bodies of all tool responses older than + * the {@code keepRecentN} most recent with a one-line placeholder, while + * preserving the toolCallId and tool name so the assistant/tool pairing + * remains valid and the model still sees "I called X earlier" in history. + * + *

Complementary to {@link #pruneOldToolResultsForModelInput}: that pass + * targets oversized or duplicate bodies regardless of age (and may spill + * to disk); this one targets aged bodies regardless of size. Both can run + * in any order — the intersection collapses to the same placeholder. + * + *

Spill-marker bodies retain their on-disk {@code path=} pointer + * inside the placeholder so a later {@code read_file} can still recover + * the original output. {@link #PRUNE_EXEMPT_TOOLS} (sub-agent delegations) + * bypass the pass entirely — their transcripts are not replayable. + * + * @param messages full conversation in chronological order + * @param keepRecentN number of newest {@link ToolResponseMessage}s kept + * verbatim; older ones are compacted. Negative or zero + * disables the pass. + */ + public List compactAgedToolResponses(List messages, int keepRecentN) { + if (messages == null || messages.isEmpty() || keepRecentN <= 0) { + return messages; + } + List out = new ArrayList<>(messages); + int seen = 0; + int compacted = 0; + boolean anyChange = false; + for (int i = out.size() - 1; i >= 0; i--) { + if (!(out.get(i) instanceof ToolResponseMessage trm)) { + continue; + } + if (seen < keepRecentN) { + seen++; + continue; + } + seen++; + + List newResponses = + new ArrayList<>(trm.getResponses().size()); + boolean messageChanged = false; + for (ToolResponseMessage.ToolResponse r : trm.getResponses()) { + String body = r.responseData(); + String name = r.name(); + boolean exempt = name != null && PRUNE_EXEMPT_TOOLS.contains(name); + if (exempt || body == null || body.isEmpty()) { + newResponses.add(r); + continue; + } + String placeholder = buildAgedPlaceholder(name, body); + if (placeholder.length() < body.length()) { + newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), name, placeholder)); + messageChanged = true; + compacted++; + } else { + // Body is already shorter than the placeholder would be — + // collapsing it would only add tokens. Keep verbatim. + newResponses.add(r); + } + } + if (messageChanged) { + out.set(i, ToolResponseMessage.builder().responses(newResponses).build()); + anyChange = true; + } + } + if (compacted > 0) { + log.info("[ConversationWindow] Aged-compacted {} tool response entries (keepRecent={}) before model request", + compacted, keepRecentN); + } + return anyChange ? out : messages; + } + + /** + * Build the one-line "old tool output cleared" body. When the original + * was a spill marker, extract its {@code path=} hint so the model can + * still recover the full output via {@code read_file} on demand. + */ + static String buildAgedPlaceholder(String toolName, String body) { + String safeName = (toolName == null || toolName.isBlank()) ? "tool" : toolName; + if (body != null && body.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)) { + int idx = body.indexOf(" path="); + if (idx >= 0) { + int end = body.indexOf('\n', idx); + String path = (end > 0 ? body.substring(idx + 6, end) : body.substring(idx + 6)).trim(); + if (!path.isEmpty()) { + return "[Old tool output cleared — '" + safeName + + "' result was spilled to " + path + + "; use read_file on that path if you still need it.]"; + } + } + } + return "[Old tool output cleared — '" + safeName + + "' can be called again if its result is needed.]"; + } + /** * Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。 *

Spill-marker responses are left untouched so their on-disk pointer diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index b18a9c4a..15390d7c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -90,6 +90,17 @@ public class ReasoningNode implements NodeAction { */ private static final int MAX_EMPTY_COMPLETION_RETRIES = 2; + /** + * Number of newest tool-response messages kept verbatim in the model + * input; older ones have their bodies collapsed to a one-line "old + * output cleared" placeholder while keeping the toolCallId / tool name + * so the assistant/tool pairing remains valid. The latest few results + * are what the model is reasoning over right now — beyond that, the + * content is history and re-call (or read_file on the spill path) is + * cheaper than carrying every previous body forward across iterations. + */ + private static final int KEEP_RECENT_TOOL_RESPONSES = 3; + /** Continuation nudge appended to the prompt when the model returns an empty turn. */ private static final String EMPTY_COMPLETION_NUDGE = "Your previous turn was empty. If the task is not yet complete, continue now " @@ -514,6 +525,15 @@ public class ReasoningNode implements NodeAction { } if (conversationWindowManager != null) { + // Age-based compaction first: drop the body of tool responses + // older than the K most recent into a one-line placeholder that + // keeps the toolCallId / tool name (so the assistant/tool pair + // stays valid) and, for spilled bodies, preserves the on-disk + // path so read_file can still recover the original. Without + // this, even spilled previews (~1-2 KB each) accumulate across + // 30+ tool calls and bloat the prompt the model sees every turn. + messages = conversationWindowManager.compactAgedToolResponses( + messages, KEEP_RECENT_TOOL_RESPONSES); // Pass conversationId + workspaceBasePath so oversized older // tool results can be spilled to the workspace spill directory // (preserving the full body for read_file recovery) instead of diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/CompactAgedToolResponsesTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/CompactAgedToolResponsesTest.java new file mode 100644 index 00000000..bb13a159 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/CompactAgedToolResponsesTest.java @@ -0,0 +1,158 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.agent.graph.executor.ToolResultStorage; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link ConversationWindowManager#compactAgedToolResponses} — the + * age-based pass that drops bodies of tool responses older than the K most + * recent into a placeholder while preserving the toolCallId / tool name so + * the model still sees "I called X earlier" in history. Complements (does + * not replace) the existing size/dedup-based prune pass. + */ +class CompactAgedToolResponsesTest { + + private static final ConversationWindowManager MANAGER = new ConversationWindowManager( + null, null, null); + + private static ToolResponseMessage toolResp(String id, String name, String body) { + return ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse(id, name, body))) + .build(); + } + + private static String spillBody(String tool, String path) { + return ToolResultStorage.SPILL_MARKER_PREFIX + " tool=" + tool + " full_chars=12345 path=" + + path + + "\n[Preview — first 800 of 12345 chars. The preview is INCOMPLETE: use read_file]\n" + + "\n…[truncated]"; + } + + @Test + @DisplayName("keepRecentN=0 or negative is a no-op (returns same list reference).") + void zeroKeepIsNoop() { + List in = List.of(toolResp("t1", "search", "a body".repeat(50))); + assertSame(in, MANAGER.compactAgedToolResponses(in, 0)); + assertSame(in, MANAGER.compactAgedToolResponses(in, -1)); + } + + @Test + @DisplayName("Latest K tool responses are kept verbatim; older ones get the placeholder.") + void keepsLatestKVerbatim() { + String body = "abcdef".repeat(50); // 300 chars — guaranteed larger than placeholder + List in = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + in.add(toolResp("call-" + i, "search", "result-" + i + " " + body)); + } + List out = MANAGER.compactAgedToolResponses(in, 2); + // out[3] and out[4] are the two newest — verbatim. + assertTrue(((ToolResponseMessage) out.get(3)).getResponses().get(0).responseData().contains("result-3")); + assertTrue(((ToolResponseMessage) out.get(4)).getResponses().get(0).responseData().contains("result-4")); + // out[0..2] are older — compacted. + for (int i = 0; i <= 2; i++) { + String compactedBody = ((ToolResponseMessage) out.get(i)).getResponses().get(0).responseData(); + assertTrue(compactedBody.startsWith("[Old tool output cleared"), + "expected placeholder at index " + i + ", got: " + compactedBody); + } + } + + @Test + @DisplayName("Tool name and toolCallId survive the rewrite so the assistant/tool pair stays valid.") + void preservesIdAndName() { + String big = "a".repeat(500); + List in = List.of( + toolResp("call-old", "search", big), + toolResp("call-mid", "search", big), + toolResp("call-new", "search", big)); + List out = MANAGER.compactAgedToolResponses(in, 1); + ToolResponseMessage.ToolResponse old = ((ToolResponseMessage) out.get(0)).getResponses().get(0); + assertEquals("call-old", old.id()); + assertEquals("search", old.name()); + assertNotEquals(big, old.responseData()); + // Latest stays verbatim. + assertEquals(big, ((ToolResponseMessage) out.get(2)).getResponses().get(0).responseData()); + } + + @Test + @DisplayName("Spill-marker bodies keep their on-disk path inside the placeholder for read_file recovery.") + void spillPathPreserved() { + String body = spillBody("browser_use", "/tmp/mateclaw/tool-results/conv-1/tool_abc.txt"); + List in = List.of( + toolResp("call-old", "browser_use", body), + toolResp("call-new", "search", "a".repeat(500))); + List out = MANAGER.compactAgedToolResponses(in, 1); + String compacted = ((ToolResponseMessage) out.get(0)).getResponses().get(0).responseData(); + assertTrue(compacted.contains("/tmp/mateclaw/tool-results/conv-1/tool_abc.txt"), + "spill path should be preserved in placeholder: " + compacted); + assertTrue(compacted.contains("read_file"), compacted); + } + + @Test + @DisplayName("Exempt tools (delegateToAgent / delegateParallel) skip compaction entirely.") + void exemptToolsSkipped() { + String big = "x".repeat(500); + List in = List.of( + toolResp("d-old", "delegateToAgent", big), + toolResp("s-old", "search", big), + toolResp("s-new", "search", big)); + List out = MANAGER.compactAgedToolResponses(in, 1); + assertEquals(big, ((ToolResponseMessage) out.get(0)).getResponses().get(0).responseData()); // exempt + assertTrue(((ToolResponseMessage) out.get(1)).getResponses().get(0).responseData() + .startsWith("[Old tool output cleared")); // non-exempt aged → compacted + assertEquals(big, ((ToolResponseMessage) out.get(2)).getResponses().get(0).responseData()); // newest kept + } + + @Test + @DisplayName("A body shorter than the placeholder itself is kept verbatim — no negative savings.") + void tinyBodyNotInflated() { + List in = List.of( + toolResp("old", "ping", "ok"), + toolResp("new", "ping", "ok")); + List out = MANAGER.compactAgedToolResponses(in, 1); + assertEquals("ok", ((ToolResponseMessage) out.get(0)).getResponses().get(0).responseData()); + } + + @Test + @DisplayName("Non-tool messages (user / assistant) are passed through untouched.") + void nonToolMessagesUntouched() { + Message user = new UserMessage("hello"); + Message assistant = new AssistantMessage("hi"); + List in = List.of(user, assistant, + toolResp("old", "search", "x".repeat(500)), + toolResp("new", "search", "y".repeat(500))); + List out = MANAGER.compactAgedToolResponses(in, 1); + assertSame(user, out.get(0)); + assertSame(assistant, out.get(1)); + } + + @Test + @DisplayName("buildAgedPlaceholder spill-path extraction handles trailing newline boundary.") + void buildPlaceholderSpillPath() { + String body = spillBody("browser_use", "/a/b/c.txt"); + String out = ConversationWindowManager.buildAgedPlaceholder("browser_use", body); + assertNotNull(out); + assertTrue(out.contains("/a/b/c.txt"), out); + } + + @Test + @DisplayName("buildAgedPlaceholder falls back to plain text when no spill path is present.") + void buildPlaceholderPlainBody() { + String out = ConversationWindowManager.buildAgedPlaceholder("search", "regular result body"); + assertTrue(out.contains("'search'"), out); + assertTrue(out.contains("can be called again"), out); + } +}