feat(context): anchor the first user message after compaction so long tasks remember the original goal

This commit is contained in:
matevip 2026-05-13 08:50:15 +08:00
parent 37894a6978
commit 7015d1513c
3 changed files with 313 additions and 2 deletions

View File

@ -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.
*
* <p>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<Message> 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 <em>real</em> 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.
*
* <p>Sizing rules:
* <ul>
* <li> {@code firstUserAnchorMaxTokens}: keep the original text verbatim.</li>
* <li> 3× the budget: head+tail truncate to the budget so most of
* the prompt-cache benefit survives.</li>
* <li>&gt; 3× the budget: degrade to a 200-char pointer line so we
* don't blow prompt cache or the summary budget on a single
* message that was probably a pasted spec the model can re-read
* from the workspace anyway.</li>
* </ul>
*
* <p>Always returns a {@link UserMessage}. {@code null} when anchoring
* is disabled, no real first user exists in the prefix, or the body is
* blank.
*
* <p>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<Message> 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
*/

View File

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

View File

@ -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.
*
* <p>Invariants verified here:
* <ul>
* <li>Anchors are always {@link UserMessage}s, never SystemMessages
* (preventing privilege escalation of historical user input).</li>
* <li>The anchor reflects the FIRST <em>real</em> user message prior
* summaries and prior anchors are skipped, otherwise iterative
* compaction would anchor compressor output.</li>
* <li>Body sizing degrades gracefully: verbatim budget, head+tail
* within 3× budget, pointer line above 3×.</li>
* </ul>
*/
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);
}
}