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);
+ }
+}