feat(agent): age-based compaction of older tool-response bodies

This commit is contained in:
matevip 2026-05-24 23:00:11 +08:00
parent 05289e6bdb
commit e953f8be5a
3 changed files with 274 additions and 0 deletions

View File

@ -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.
*
* <p>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.
*
* <p>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<Message> compactAgedToolResponses(List<Message> messages, int keepRecentN) {
if (messages == null || messages.isEmpty() || keepRecentN <= 0) {
return messages;
}
List<Message> 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<ToolResponseMessage.ToolResponse> 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 字符
* <p>Spill-marker responses are left untouched so their on-disk pointer

View File

@ -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

View File

@ -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"
+ "<preview content>\n…[truncated]";
}
@Test
@DisplayName("keepRecentN=0 or negative is a no-op (returns same list reference).")
void zeroKeepIsNoop() {
List<Message> 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<Message> in = new ArrayList<>();
for (int i = 0; i < 5; i++) {
in.add(toolResp("call-" + i, "search", "result-" + i + " " + body));
}
List<Message> 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<Message> in = List.of(
toolResp("call-old", "search", big),
toolResp("call-mid", "search", big),
toolResp("call-new", "search", big));
List<Message> 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<Message> in = List.of(
toolResp("call-old", "browser_use", body),
toolResp("call-new", "search", "a".repeat(500)));
List<Message> 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<Message> in = List.of(
toolResp("d-old", "delegateToAgent", big),
toolResp("s-old", "search", big),
toolResp("s-new", "search", big));
List<Message> 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<Message> in = List.of(
toolResp("old", "ping", "ok"),
toolResp("new", "ping", "ok"));
List<Message> 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<Message> in = List.of(user, assistant,
toolResp("old", "search", "x".repeat(500)),
toolResp("new", "search", "y".repeat(500)));
List<Message> 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);
}
}