mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(context): anchor the first user message after compaction so long tasks remember the original goal
This commit is contained in:
parent
37894a6978
commit
7015d1513c
@ -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>> 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。
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user