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 09e6d19f..a50e8753 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 @@ -62,12 +62,21 @@ public class ConversationWindowManager { /** 迭代更新:合并旧摘要 + 新轮次 */ private static final String STRUCTURED_SUMMARY_UPDATE = PromptLoader.loadPrompt("context/structured-summary-update"); - /** 摘要注入前缀 */ - private static final String SUMMARY_PREFIX = + /** 摘要注入前缀 (package-private for test assertions) */ + static final String SUMMARY_PREFIX = "[上下文压缩] 更早的对话轮次已被压缩为摘要以节省上下文空间。" + "以下摘要描述了已完成的工作,当前会话状态可能已反映这些变更。" + "请基于摘要和当前状态继续,避免重复已完成的工作:\n\n"; + /** + * Marker prefix used by the first-user anchor. Lets compaction skip + * previously-injected anchors when looking for the "real" first user + * message in a subsequent round. + * + *

Package-private so unit tests can assert on the marker. + */ + static final String ANCHOR_PREFIX = "[Original goal]\n"; + // ==================== 序列化截断参数 ==================== private static final int CONTENT_MAX = 6000; @@ -363,6 +372,15 @@ public class ConversationWindowManager { List result = new ArrayList<>(); if (summary != null && !summary.isBlank()) { result.add(new UserMessage(SUMMARY_PREFIX + summary)); + + // Anchor the original user goal so a long task that paged through + // dozens of turns can still see what was originally asked. Always + // as a UserMessage — promoting historical user input to a + // SystemMessage would be a privilege-escalation risk. + Message anchor = buildFirstUserAnchor(oldMessages); + if (anchor != null) { + result.add(anchor); + } } else if (!oldMessages.isEmpty()) { log.warn("[ConversationWindow] 摘要生成失败,降级为保留最近 4 条旧消息, conv={}", conversationId); int fallbackKeep = Math.min(4, oldMessages.size()); @@ -526,6 +544,94 @@ public class ConversationWindowManager { return cut; } + /** + * Build an anchor message replaying the first real user input + * found in the compressed prefix. "Real" here excludes prior + * compaction artifacts ({@link #SUMMARY_PREFIX} / {@link #ANCHOR_PREFIX} + * messages from earlier rounds), because anchoring the previous + * summary defeats the purpose — the model would just see "[Original + * goal] [上下文压缩] …" pointing at compressor output, not at the user's + * actual request. + * + *

Sizing rules: + *

+ * + *

Always returns a {@link UserMessage}. {@code null} when anchoring + * is disabled, no real first user exists in the prefix, or the body is + * blank. + * + *

Package-private for direct unit testing — the surrounding + * {@link #compactMessages} path needs a ChatModel and the whole + * structured-summary pipeline, which the anchor logic does not. + */ + Message buildFirstUserAnchor(List oldMessages) { + if (!properties.isFirstUserAnchorEnabled()) { + return null; + } + UserMessage firstUser = null; + for (Message m : oldMessages) { + if (!(m instanceof UserMessage um)) continue; + String text = um.getText(); + if (text == null) continue; + // Skip synthetic prior-round artifacts. + if (text.startsWith(SUMMARY_PREFIX) || text.startsWith(ANCHOR_PREFIX)) { + continue; + } + firstUser = um; + break; + } + if (firstUser == null) return null; + + String text = firstUser.getText(); + if (text == null || text.isBlank()) return null; + + int maxAnchorTokens = Math.max(40, properties.getFirstUserAnchorMaxTokens()); + int textTokens = TokenEstimator.estimateTokens(text); + + if (textTokens <= maxAnchorTokens) { + return new UserMessage(ANCHOR_PREFIX + text); + } + + // > 3× budget: cheap pointer line so we don't pay token tax for a + // gigantic pasted spec. The model still knows the original goal + // existed without seeing the full body. + if (textTokens > maxAnchorTokens * 3L) { + int pointerChars = Math.min(text.length(), 200); + String pointer = text.substring(0, pointerChars).stripTrailing() + + (text.length() > pointerChars ? "..." : ""); + log.info("[ConversationWindow] First-user anchor downgraded to pointer ({} tokens > 3× budget {})", + textTokens, maxAnchorTokens); + return new UserMessage(ANCHOR_PREFIX + pointer); + } + + // Within 3× — head+tail truncate to the budget. The 2 chars/token + // ratio is a deliberate over-estimate so the anchor never inflates + // past the configured budget on ASCII-heavy input. + int budgetChars = Math.max(160, maxAnchorTokens * 2); + if (budgetChars >= text.length()) { + return new UserMessage(ANCHOR_PREFIX + text); + } + int headLen = (int) (budgetChars * 0.6); + int tailLen = Math.max(40, budgetChars - headLen - 40); + if (headLen + tailLen >= text.length()) { + return new UserMessage(ANCHOR_PREFIX + text); + } + String truncated = text.substring(0, headLen) + + "\n...[" + (text.length() - headLen - tailLen) + " chars truncated]...\n" + + text.substring(text.length() - tailLen); + log.info("[ConversationWindow] First-user anchor head+tail truncated ({} -> ~{} chars)", + text.length(), truncated.length()); + return new UserMessage(ANCHOR_PREFIX + truncated); + } + /** * 计算摘要字数预算:被压缩内容 token 的 20%,不低于 500、不超过 3000。 */ diff --git a/mateclaw-server/src/main/java/vip/mate/config/ConversationWindowProperties.java b/mateclaw-server/src/main/java/vip/mate/config/ConversationWindowProperties.java index 5df3913b..e69483ae 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/ConversationWindowProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/config/ConversationWindowProperties.java @@ -50,4 +50,26 @@ public class ConversationWindowProperties { * Set to 0 to always attempt compaction whenever a pair-safe cut exists. */ private int pairSafeMinPrefixToCompact = 2; + + /** + * After compaction, re-inject the first user message of the compressed + * prefix so the original goal stays anchored in the prompt even when a + * long task has paged through dozens of turns. Without an anchor the + * structured summary alone can drift, and the model may forget what was + * being asked. Injected as a {@link org.springframework.ai.chat.messages.UserMessage} + * (never SystemMessage) so historical user input cannot be promoted to a + * system-level instruction. + */ + private boolean firstUserAnchorEnabled = true; + + /** + * Maximum tokens the anchor body is allowed to consume in the prompt. + * The first user message is often short ("write me a CLI tool that…"), + * but power users sometimes paste multi-KB specs. When the body fits + * the budget it stays verbatim; when it is up to 3× over, it is + * head+tail truncated to this budget; when it is more than 3× over, + * it degrades to a 200-char pointer line so the model still knows the + * original goal existed without blowing prompt-cache or summary budget. + */ + private int firstUserAnchorMaxTokens = 400; } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerAnchorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerAnchorTest.java new file mode 100644 index 00000000..8a771137 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerAnchorTest.java @@ -0,0 +1,183 @@ +package vip.mate.agent.context; + +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.UserMessage; +import vip.mate.config.ConversationWindowProperties; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * First-user anchor injection — the artifact re-introduced into the + * compacted prompt so the model never loses sight of what the user + * originally asked, even after the actual first turn has been compressed + * into a structured summary. + * + *

Invariants verified here: + *

+ */ +class ConversationWindowManagerAnchorTest { + + @Test + void shortFirstUserStaysVerbatim() { + ConversationWindowManager mgr = newManager(true, 400); + + String goal = "find the bug in foo.js"; + Message anchor = mgr.buildFirstUserAnchor(List.of( + new UserMessage(goal), + new AssistantMessage("looking into it") + )); + + assertInstanceOf(UserMessage.class, anchor); + String text = anchor.getText(); + assertTrue(text.startsWith(ConversationWindowManager.ANCHOR_PREFIX)); + assertTrue(text.contains(goal), + "short goals fit the budget verbatim, no truncation marker should appear"); + } + + @Test + void anchorIsAlwaysUserMessageNeverSystem() { + ConversationWindowManager mgr = newManager(true, 400); + + Message anchor = mgr.buildFirstUserAnchor(List.of( + new UserMessage("rewrite this README") + )); + + // Critical safety property: never promote historical user input into a SystemMessage. + assertInstanceOf(UserMessage.class, anchor); + } + + @Test + void disabledAnchorReturnsNull() { + ConversationWindowManager mgr = newManager(false, 400); + + Message anchor = mgr.buildFirstUserAnchor(List.of( + new UserMessage("anything") + )); + + assertNull(anchor); + } + + @Test + void noUserInPrefixReturnsNull() { + ConversationWindowManager mgr = newManager(true, 400); + + // Prefix is all assistant messages — no user goal to anchor. + Message anchor = mgr.buildFirstUserAnchor(List.of( + new AssistantMessage("blah"), + new AssistantMessage("more blah") + )); + + assertNull(anchor); + } + + @Test + void previousSummaryAndPriorAnchorAreSkipped() { + ConversationWindowManager mgr = newManager(true, 400); + + String realGoal = "ship a feature flag for the new pricing page"; + Message anchor = mgr.buildFirstUserAnchor(List.of( + // round-2 prefix: starts with a previous summary, then a prior anchor, + // then the actual original user message. + new UserMessage(ConversationWindowManager.SUMMARY_PREFIX + "earlier summary text"), + new UserMessage(ConversationWindowManager.ANCHOR_PREFIX + "stale anchor from prior round"), + new UserMessage(realGoal), + new AssistantMessage("on it") + )); + + assertNotNull(anchor); + assertTrue(anchor.getText().contains(realGoal), + "anchor must reflect the REAL first user message, not a prior summary or prior anchor"); + } + + @Test + void mediumOverBudgetIsHeadTailTruncated() { + // 80-token budget → roughly 160-char head+tail target. + ConversationWindowManager mgr = newManager(true, 80); + + // ~400 chars — within 3× the budget so head+tail truncation should apply. + String body = "a".repeat(200) + "MIDDLE" + "b".repeat(200); + Message anchor = mgr.buildFirstUserAnchor(List.of(new UserMessage(body))); + + assertNotNull(anchor); + String text = anchor.getText(); + assertTrue(text.contains("...["), + "head+tail truncation marker should be present"); + assertTrue(text.length() < body.length(), + "anchor must be smaller than original (was " + text.length() + " vs " + body.length() + ")"); + // Head and tail of the original body must both be present. + assertTrue(text.startsWith(ConversationWindowManager.ANCHOR_PREFIX)); + // The first run of 'a's should still be there + assertTrue(text.contains("aaaaaaaaaa")); + // And the tail run of 'b's + assertTrue(text.contains("bbbbbbbbbb")); + } + + @Test + void hugeBodyDegradesToPointerLine() { + ConversationWindowManager mgr = newManager(true, 80); + + // > 3× the budget → pointer-only path. + String body = "X".repeat(5000); + Message anchor = mgr.buildFirstUserAnchor(List.of(new UserMessage(body))); + + assertNotNull(anchor); + String text = anchor.getText(); + assertTrue(text.length() < 500, + "pointer line should be far smaller than the body (was " + text.length() + ")"); + assertTrue(text.endsWith("..."), + "pointer line should end with the truncation marker"); + } + + @Test + void blankUserMessageReturnsNull() { + ConversationWindowManager mgr = newManager(true, 400); + + Message anchor = mgr.buildFirstUserAnchor(List.of( + new UserMessage(""), + new AssistantMessage("ack") + )); + + // No real goal text — nothing to anchor. + assertNull(anchor); + } + + @Test + void anchorPrefixIsConsistent() { + ConversationWindowManager mgr = newManager(true, 400); + + Message a = mgr.buildFirstUserAnchor(List.of(new UserMessage("short"))); + Message b = mgr.buildFirstUserAnchor(List.of(new UserMessage("a different short goal"))); + + // Stable marker — downstream code (and the dedup in buildFirstUserAnchor itself) + // depends on this prefix being constant. + assertEquals(ConversationWindowManager.ANCHOR_PREFIX, + a.getText().substring(0, ConversationWindowManager.ANCHOR_PREFIX.length())); + assertEquals(ConversationWindowManager.ANCHOR_PREFIX, + b.getText().substring(0, ConversationWindowManager.ANCHOR_PREFIX.length())); + } + + // ------------------------------------------------------------------ helpers + + private static ConversationWindowManager newManager(boolean enabled, int maxAnchorTokens) { + ConversationWindowProperties props = new ConversationWindowProperties(); + props.setFirstUserAnchorEnabled(enabled); + props.setFirstUserAnchorMaxTokens(maxAnchorTokens); + return new ConversationWindowManager(props, null, null); + } +}