mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent): age-based compaction of older tool-response bodies
This commit is contained in:
parent
05289e6bdb
commit
e953f8be5a
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user