feat(agent): structured compaction on prompt-too-long preserves prefix

This commit is contained in:
matevip 2026-05-16 14:51:14 +08:00
parent b9c0ead024
commit 419ee57cc8
4 changed files with 604 additions and 38 deletions

View File

@ -162,6 +162,21 @@ public class ConversationWindowManager {
/** 每个会话的摘要冷却截止时间 */
private final ConcurrentHashMap<String, Long> summaryCooldownUntil = new ConcurrentHashMap<>();
/** Per-conversation last-PTL-forced-compaction timestamp. The structured
* PTL retry path is guarded by {@link #PTL_FORCE_LLM_COOLDOWN_MS} a
* second PTL hit within the cooldown falls straight back to tail-only
* trimming. Without this, a model that keeps regenerating tool-call
* loops can drive a chain of summary-LLM calls and lock the
* conversation in a compaction storm. */
private final ConcurrentHashMap<String, Long> ptlForceCompactAt = new ConcurrentHashMap<>();
/** Cooldown window after a structured PTL compaction during which a
* follow-up PTL is downgraded to tail-only. Picked so a single ReAct
* loop that retries within seconds can't burn another summary LLM
* call, while still letting the next real conversation turn (minutes
* later) get a fresh structured pass. */
private static final long PTL_FORCE_LLM_COOLDOWN_MS = 60_000L;
// ==================== 主入口 ====================
/**
@ -263,7 +278,7 @@ public class ConversationWindowManager {
int tailTokenBudget = (int) (triggerThreshold * 0.20);
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel,
conversationId, agentId, totalTokens, spillsAtEntry);
conversationId, agentId, totalTokens, spillsAtEntry, "token_threshold");
}
/**
@ -298,11 +313,12 @@ public class ConversationWindowManager {
private List<Message> compactMessages(List<Message> messages, int historyBudget,
int tailTokenBudget, ChatModel chatModel,
String conversationId, Long agentId,
int preTokens, long spillsAtEntry) {
int preTokens, long spillsAtEntry,
String trigger) {
broadcastCompactStatus(conversationId, "start", Map.of(
"preTokens", preTokens,
"messagesIn", messages.size(),
"trigger", "token_threshold"
"trigger", trigger
));
// 动态计算尾部保护边界替代固定 preserveRecentPairs
@ -457,7 +473,7 @@ public class ConversationWindowManager {
? Math.max(0L, toolResultStorage.getSpillCount() - spillsAtEntry)
: 0L;
Map<String, Object> boundaryMetadata = new java.util.LinkedHashMap<>();
boundaryMetadata.put("trigger", "token_threshold");
boundaryMetadata.put("trigger", trigger);
boundaryMetadata.put("preTokens", preTokens);
boundaryMetadata.put("postTokens", resultTokens);
boundaryMetadata.put("messagesSummarized", oldMessages.size());
@ -1114,6 +1130,118 @@ public class ConversationWindowManager {
// ==================== PTL 紧急压缩 ====================
/**
* Structured PTL (Prompt Too Long) recovery reuses the full
* {@link #compactMessages} pipeline (pair-safe boundary, soft/hard
* trim, MemoryProvider hook, LLM summary, anchor of the first user
* goal) under a forced-tight history budget so the retry actually fits.
* <p>
* Differences vs the {@link #compactForRetry(List)} fallback:
* <ul>
* <li>Preserves the original user goal via anchor instead of dropping
* it with the head long tasks lose context every PTL otherwise.</li>
* <li>Pair-safe cuts, so the retry doesn't break a
* {@code AssistantMessage.tool_calls} / {@code ToolResponseMessage}
* cluster and 400 the provider a second time.</li>
* <li>Runs through summary generation so semantic continuity (user
* preferences, completed steps) survives the trim.</li>
* <li>Tags the persisted boundary row with
* {@code trigger=prompt_too_long} so the summary is retrievable
* via the same {@code mate_conversation_summary} schema as a
* normal token-threshold compaction.</li>
* </ul>
* <p>
* A 60s cooldown ({@link #PTL_FORCE_LLM_COOLDOWN_MS}) downgrades the
* second-and-subsequent PTL hit on one conversation to tail-only, so
* a model stuck in a tool-call retry loop can't drag the summary LLM
* along with it.
*
* @param messages Current history that overflowed the model window.
* @param chatModel Used for the summary generation step.
* @param conversationId Cooldown / cache key.
* @param agentId Drives the {@code MemoryProvider.onPreCompress}
* hook. Nullable the hook is a no-op when null.
* @return Compacted history with summary + anchor + tail, or the
* {@link #compactForRetry(List)} tail-only fallback when the
* cooldown is active or the structured pass produces no
* reduction. {@code null} when the input is too small to
* compact (matches the legacy contract).
*/
public List<Message> compactForRetry(List<Message> messages,
ChatModel chatModel,
String conversationId,
Long agentId) {
if (messages == null || messages.size() <= 2) {
return null;
}
// Sweep the cooldown map on every PTL entry. The summaryCache sweep
// already covers normal-compaction traffic via fitToWindow; without
// this call here, a conversation that only ever hits PTL never
// releases its ptlForceCompactAt entry.
evictExpiredEntries();
// Race-safe claim: compute is atomic per key, so two concurrent
// PTL hits on the same conv can't both pass the cooldown check.
// The {@code claimed} flag is set inside the atomic block so we can
// distinguish "this call's stamp won" from "previous call's stamp
// happened to equal our now" (Windows clock has 15 ms granularity —
// identity-on-timestamp would misfire for back-to-back invocations).
long now = System.currentTimeMillis();
final boolean[] claimed = {false};
ptlForceCompactAt.compute(conversationId, (k, prev) -> {
if (prev != null && now - prev < PTL_FORCE_LLM_COOLDOWN_MS) {
claimed[0] = false;
return prev;
}
claimed[0] = true;
return now;
});
if (!claimed[0]) {
long prevStamp = ptlForceCompactAt.getOrDefault(conversationId, now);
long remainingMs = Math.max(0L, PTL_FORCE_LLM_COOLDOWN_MS - (now - prevStamp));
log.warn("[ConversationWindow] PTL cooldown active for conv={} (remaining {} ms), falling back to tail-only",
conversationId, remainingMs);
broadcastCompactStatus(conversationId, "ptl_cooldown_skipped", Map.of(
"trigger", "prompt_too_long",
"cooldownRemainingMs", remainingMs));
return compactForRetry(messages);
}
int currentTokens = TokenEstimator.estimateTokens(messages);
// Force the history budget into the bottom quartile of current size
// but never under 2k so the post-trim window still has room for
// summary + anchor + a couple of recent turns. Tail budget is one
// quarter of that so the recent window doesn't dominate.
int forcedBudget = Math.max(2000, currentTokens / 4);
int forcedTailBudget = forcedBudget / 4;
log.warn("[ConversationWindow] PTL forced compaction: messages={}, currentTokens={}, forcedBudget={}, forcedTail={}",
messages.size(), currentTokens, forcedBudget, forcedTailBudget);
// Note: no separate "ptl_start" broadcast the inner compactMessages
// call broadcasts "start" with trigger="prompt_too_long" in its
// payload, which is sufficient differentiation for the frontend
// (one event per compaction, with the trigger field carrying the
// semantic distinction).
// Spill count is the manager's private view of toolResultStorage
// computed inside the manager so callers don't need to touch the
// storage SPI.
long spillsAtEntry = (toolResultStorage != null) ? toolResultStorage.getSpillCount() : 0L;
List<Message> compacted = compactMessages(messages, forcedBudget, forcedTailBudget,
chatModel, conversationId, agentId, currentTokens, spillsAtEntry,
"prompt_too_long");
if (compacted == messages || TokenEstimator.estimateTokens(compacted) >= currentTokens) {
log.warn("[ConversationWindow] PTL structured compaction had no effect for conv={}, falling back to tail-only",
conversationId);
return compactForRetry(messages);
}
return compacted;
}
/**
* PTL (Prompt Too Long) 恢复用的紧急压缩
* 不调用 LLM 摘要直接丢弃较旧消息只保留最近 4
@ -1155,6 +1283,8 @@ public class ConversationWindowManager {
private void evictExpiredEntries() {
summaryCache.entrySet().removeIf(entry -> entry.getValue().isExpired(CACHE_TTL_MS));
long ptlCutoff = System.currentTimeMillis() - PTL_FORCE_LLM_COOLDOWN_MS;
ptlForceCompactAt.entrySet().removeIf(entry -> entry.getValue() < ptlCutoff);
}
record CachedSummary(String summary, long createdAt) {

View File

@ -237,17 +237,16 @@ public class ReasoningNode implements NodeAction {
// ======= 构建 Prompt =======
String systemPrompt = accessor.systemPrompt();
// RFC-049 follow-up: append a tool-use enforcement clause to every
// ReasoningNode call. Without this, models (especially DeepSeek thinking
// and Claude Opus) tend to "narrate" emit a final_answer like "现在
// 直接生成立项材料 docx" instead of actually calling renderDocx, which
// makes the graph silently terminate at final_answer_node with the
// narration as the user-facing reply.
// Append a tool-use enforcement clause to every ReasoningNode call.
// Without it, some models (notably DeepSeek thinking and Claude Opus)
// tend to "narrate" emit a final_answer like "现在直接生成立项材料
// docx" instead of actually calling renderDocx, which makes the
// graph silently terminate at final_answer_node with the narration
// as the user-facing reply.
//
// Pattern adopted from hermes-agent's TOOL_USE_ENFORCEMENT_GUIDANCE
// (`/agent/prompt_builder.py:179-191`). Appended to systemPrompt rather
// than woven into the AgentEntity-stored prompt so it stays out of the
// user-editable agent UI but is still always-on at runtime.
// Appended at runtime rather than woven into the AgentEntity-stored
// prompt so it stays out of the user-editable agent UI but is still
// always-on for the runtime LLM.
systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT;
List<Message> messages = accessor.messages();
@ -328,24 +327,16 @@ public class ReasoningNode implements NodeAction {
}
String workspaceBasePath = state.value(vip.mate.agent.graph.state.MateClawStateKeys.WORKSPACE_BASE_PATH, "");
List<Message> promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(systemPrompt));
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, "");
String userMsg = state.value(MateClawStateKeys.USER_MESSAGE, "");
// Wiki 相关性注入根据用户消息提取相关页面摘要
if (wikiContextService != null) {
String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, "");
String userMsg = state.value(MateClawStateKeys.USER_MESSAGE, "");
try {
Long parsedAgentId = Long.parseLong(agentIdStr);
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg);
if (wikiRelevant != null && !wikiRelevant.isBlank()) {
promptMessages.add(new UserMessage(wikiRelevant));
}
} catch (NumberFormatException ignored) {
// agentId 无法解析时跳过 wiki 注入
}
}
// Build the non-history prefix ONCE. The PTL retry branch below
// reuses this list verbatim so the retried prompt has exactly the
// same system / runtime context / wiki injection as the original
// the previous tail-only retry path silently dropped the wiki
// segment which led to "answer regressed after compaction"
// complaints on long sessions.
List<Message> nonHistoryPrefix = buildNonHistoryPrefix(systemPrompt, workspaceBasePath, agentIdStr, userMsg);
if (conversationWindowManager != null) {
// Pass conversationId + workspaceBasePath so oversized older
@ -355,6 +346,7 @@ public class ReasoningNode implements NodeAction {
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
messages, conversationId, workspaceBasePath);
}
List<Message> promptMessages = new ArrayList<>(nonHistoryPrefix);
promptMessages.addAll(messages);
// 请求级思考深度覆盖ThinkingLevelHolder AgentService 设置
@ -397,19 +389,34 @@ public class ReasoningNode implements NodeAction {
try {
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
// PTL 处理压缩后重试
// PTL 处理结构化压缩后重试复用 nonHistoryPrefix 保证重试
// Prompt 仍带 wiki / runtime context早期的 tail-only 路径会把
// wiki 段一起丢掉重试后的 prompt 比原始更短少一层信息
if (result.isPromptTooLong() && conversationWindowManager != null) {
log.warn("[ReasoningNode] Prompt too long, attempting compaction and retry");
List<Message> compactedMessages = conversationWindowManager.compactForRetry(messages);
log.warn("[ReasoningNode] Prompt too long, attempting STRUCTURED compaction and retry");
// MateClawStateAccessor.agentId() returns String per state
// schema; the ConversationWindowManager hook expects Long
// (nullable onPreCompress is a no-op when null).
Long agentIdLong = null;
if (!agentIdStr.isEmpty()) {
try {
agentIdLong = Long.parseLong(agentIdStr);
} catch (NumberFormatException ignored) {
// Same fallback as the non-history prefix builder above.
}
}
List<Message> compactedMessages = conversationWindowManager.compactForRetry(
messages, chatModel, conversationId, agentIdLong);
if (compactedMessages != null && compactedMessages.size() < messages.size()) {
List<Message> retryPromptMessages = new ArrayList<>();
retryPromptMessages.add(new SystemMessage(systemPrompt));
retryPromptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
// Reuse the SAME non-history prefix wiki/runtime context preserved.
List<Message> retryPromptMessages = new ArrayList<>(nonHistoryPrefix);
retryPromptMessages.addAll(compactedMessages);
Prompt retryPrompt = new Prompt(retryPromptMessages, options);
log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages",
messages.size(), compactedMessages.size());
// compact retry 是第 2 LLM 调用先递增再调用
nextLlmCallCount++;
pushPhase(conversationId, "reasoning", Map.of(
"iteration", accessor.iterationCount(),
@ -660,6 +667,55 @@ public class ReasoningNode implements NodeAction {
return out;
}
/**
* Build the part of the Prompt that does not depend on history messages:
* system prompt, workspace runtime context, and (when wiring permits) the
* wiki relevant-pages snippet. Extracted so the initial Prompt assembly
* and the PTL retry path can share one source of truth historically
* these were two parallel code paths and the retry one silently dropped
* the wiki injection.
* <p>
* {@code systemPrompt} is consumed as-is; the upstream callsite has
* already appended the tool-use enforcement clause, so this helper must
* NOT re-append it (doing so would duplicate the clause on every retry).
*
* @param systemPrompt Fully-built system prompt (with tool-use
* enforcement already appended upstream).
* @param workspaceBasePath Active workspace directory; passed to
* {@link RuntimeContextInjector}.
* @param agentIdStr Agent ID as carried in graph state parsed
* to {@code Long} only when non-empty and
* numeric; otherwise the wiki segment is
* skipped (matches the pre-refactor behavior).
* @param userMsg Current user message used by
* {@code WikiContextService} to score
* relevance.
*/
// Package-private so ReasoningNodePtlPromptTest can directly assert on
// the wiki / runtime-context layout; the production callsites inside
// this class call it via {@code this.buildNonHistoryPrefix(...)} so
// narrowing the visibility doesn't change behavior.
List<Message> buildNonHistoryPrefix(String systemPrompt,
String workspaceBasePath,
String agentIdStr,
String userMsg) {
List<Message> prefix = new ArrayList<>();
prefix.add(new SystemMessage(systemPrompt));
prefix.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
if (wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) {
try {
Long parsedAgentId = Long.parseLong(agentIdStr);
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg);
if (wikiRelevant != null && !wikiRelevant.isBlank()) {
prefix.add(new UserMessage(wikiRelevant));
}
} catch (NumberFormatException ignored) {
// agentId not numeric skip wiki injection (matches prior behavior).
}
}
return prefix;
}
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
if (streamTracker == null || !StringUtils.hasText(conversationId)) {
return;

View File

@ -0,0 +1,230 @@
package vip.mate.agent.context;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
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 org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import vip.mate.config.ConversationWindowProperties;
import vip.mate.workspace.conversation.ConversationService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Contract for the structured PTL (Prompt Too Long) recovery path.
* <p>
* Verifies:
* <ol>
* <li>A PTL hit runs the full {@link ConversationWindowManager#compactMessages}
* pipeline (anchor + summary + tail) under a forced-tight budget, not
* the legacy tail-only drop.</li>
* <li>Tool-call clusters are kept intact across the cut so the retry
* prompt doesn't 400 the provider a second time.</li>
* <li>The persisted boundary row carries
* {@code metadata.trigger = "prompt_too_long"} so the summary is
* retrievable distinctly from a normal token-threshold compaction.</li>
* <li>A second PTL within the 60s cooldown falls back to tail-only and
* does NOT invoke the summary LLM (avoids compaction storms).</li>
* </ol>
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConversationWindowManagerPtlTest {
@Mock private ChatModel chatModel;
@Mock private ConversationService conversationService;
private ConversationWindowProperties properties;
private ConversationWindowManager manager;
@BeforeEach
void setUp() {
properties = new ConversationWindowProperties();
properties.setFirstUserAnchorEnabled(true);
properties.setFirstUserAnchorMaxTokens(400);
// memoryManager is null onPreCompress hook is a no-op.
manager = new ConversationWindowManager(properties, null, conversationService);
// ChatModel always returns a short, deterministic summary so the
// pipeline can persist a boundary row and we can assert on its
// metadata.
when(chatModel.call(any(Prompt.class))).thenAnswer(inv ->
makeChatResponse("STRUCTURED_SUMMARY_FROM_LLM"));
when(conversationService.saveCompressionSummaryReturningId(
anyString(), anyString(), anyInt(), any())).thenReturn(42L);
}
@Test
@DisplayName("PTL structured pass: returns summary + anchor + tail, no broken tool-call pair, trigger=prompt_too_long")
void structuredCompactionLandsAnchorSummaryAndTail() {
List<Message> history = buildHistoryWithToolPairs();
int sizeBefore = history.size();
List<Message> compacted = manager.compactForRetry(history, chatModel, "conv-ptl-1", 1L);
// ---- shape: not null, smaller than input ----
assertThat(compacted).isNotNull();
assertThat(compacted.size()).isLessThan(sizeBefore);
// ---- contains the structured summary marker + anchor ----
boolean hasStructuredSummary = compacted.stream()
.filter(m -> m instanceof UserMessage)
.map(Message::getText)
.anyMatch(t -> t != null
&& t.startsWith(ConversationWindowManager.SUMMARY_PREFIX)
&& t.contains("STRUCTURED_SUMMARY_FROM_LLM"));
assertThat(hasStructuredSummary)
.as("compacted history must include the LLM summary wrapped with SUMMARY_PREFIX")
.isTrue();
boolean hasAnchor = compacted.stream()
.filter(m -> m instanceof UserMessage)
.map(Message::getText)
.anyMatch(t -> t != null && t.startsWith(ConversationWindowManager.ANCHOR_PREFIX));
assertThat(hasAnchor)
.as("anchor of the original user goal must be present")
.isTrue();
// ---- pair safety: every AssistantMessage with tool_calls keeps its
// matching ToolResponseMessages adjacent ----
assertNoBrokenToolPair(compacted);
// ---- the boundary persistence path tags trigger = "prompt_too_long" ----
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> metaCaptor = ArgumentCaptor.forClass(Map.class);
verify(conversationService).saveCompressionSummaryReturningId(
org.mockito.ArgumentMatchers.eq("conv-ptl-1"),
anyString(), anyInt(), metaCaptor.capture());
assertThat(metaCaptor.getValue())
.containsEntry("trigger", "prompt_too_long")
.containsKey("preTokens")
.containsKey("postTokens");
}
@Test
@DisplayName("PTL cooldown: second call within 60s falls back to tail-only, no extra ChatModel.call")
void secondPtlWithinCooldownFallsBackToTailOnly() {
List<Message> history = buildHistoryWithToolPairs();
// First call exercises the structured path ChatModel.call invoked
// for summary generation.
manager.compactForRetry(history, chatModel, "conv-cooldown", 1L);
verify(chatModel, times(1)).call(any(Prompt.class));
// Second call within cooldown tail-only fallback, no further LLM call.
List<Message> second = manager.compactForRetry(history, chatModel, "conv-cooldown", 1L);
assertThat(second).isNotNull();
// Tail-only path drops summary + anchor no SUMMARY_PREFIX in the
// second result (this is what differentiates it from the structured
// path even when both happen to return 4 messages).
assertThat(second.stream().anyMatch(m -> {
String t = m.getText();
return t != null && t.startsWith(ConversationWindowManager.SUMMARY_PREFIX);
})).isFalse();
// Critical: the summary LLM was NOT called a second time.
verify(chatModel, times(1)).call(any(Prompt.class));
}
@Test
@DisplayName("Tiny history (≤ 2 messages) returns null without touching ChatModel")
void tinyHistoryReturnsNull() {
List<Message> tiny = List.of(new UserMessage("hi"), new AssistantMessage("hello"));
List<Message> result = manager.compactForRetry(tiny, chatModel, "conv-tiny", 1L);
assertThat(result).isNull();
verify(chatModel, never()).call(any(Prompt.class));
}
// ---------- helpers ----------
/**
* Build a 50-message history: alternating user/assistant turns plus five
* intact assistant.tool_calls ToolResponseMessage clusters scattered
* through it. Each filler message carries ~300 chars so the total token
* count is comfortably large enough to push the structured budget into
* the "needs LLM summary" range.
*/
private static List<Message> buildHistoryWithToolPairs() {
List<Message> msgs = new ArrayList<>();
String filler = "x".repeat(300);
msgs.add(new UserMessage("ORIGINAL_USER_GOAL: investigate the bug in module X"));
for (int i = 0; i < 22; i++) {
msgs.add(new AssistantMessage("assistant turn " + i + " " + filler));
msgs.add(new UserMessage("user turn " + i + " " + filler));
}
// Append five tool-call clusters at the tail half so the pair-safe cut
// has work to do (the cut may walk through them).
for (int i = 0; i < 5; i++) {
String callId = "call-" + i;
AssistantMessage call = AssistantMessage.builder()
.content("calling tool " + i)
.toolCalls(List.of(new AssistantMessage.ToolCall(
callId, "function", "search", "{\"q\":\"q" + i + "\"}")))
.build();
ToolResponseMessage response = ToolResponseMessage.builder()
.responses(List.of(new ToolResponseMessage.ToolResponse(
callId, "search", "result body " + i + " " + filler)))
.build();
msgs.add(call);
msgs.add(response);
}
return msgs;
}
/**
* Assert that every {@link AssistantMessage} carrying a non-empty
* {@code tool_calls} block in {@code messages} is immediately followed
* by at least one matching {@link ToolResponseMessage}, with one
* response per call id. Catches the "boundary split through a tool
* pair" failure mode the structured PTL path is specifically built to
* avoid.
*/
private static void assertNoBrokenToolPair(List<Message> messages) {
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof AssistantMessage am
&& am.getToolCalls() != null && !am.getToolCalls().isEmpty()) {
// Every call id must appear in subsequent ToolResponseMessage(s)
// before any other AssistantMessage shows up.
java.util.Set<String> outstanding = new java.util.LinkedHashSet<>();
for (var c : am.getToolCalls()) outstanding.add(c.id());
for (int j = i + 1; j < messages.size() && !outstanding.isEmpty(); j++) {
Message next = messages.get(j);
if (next instanceof ToolResponseMessage trm) {
for (var r : trm.getResponses()) outstanding.remove(r.id());
} else if (next instanceof AssistantMessage) {
break;
}
}
assertThat(outstanding)
.as("AssistantMessage at index %d has unmatched tool_call ids", i)
.isEmpty();
}
}
}
private static ChatResponse makeChatResponse(String text) {
AssistantMessage am = new AssistantMessage(text);
return new ChatResponse(List.of(new Generation(am)));
}
}

View File

@ -0,0 +1,150 @@
package vip.mate.agent.graph.node;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import vip.mate.agent.AgentToolSet;
import vip.mate.wiki.service.WikiContextService;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Regression guard: wiki / runtime-context content must survive a
* {@code prompt_too_long} retry.
* <p>
* An earlier PTL retry path in {@code ReasoningNode} reassembled the prompt
* with only the system prompt + workspace runtime context the wiki
* relevant snippet that the initial assembly injected was silently dropped,
* so the retried prompt asked the model the same question with strictly
* less context. {@code buildNonHistoryPrefix(...)} now centralises the
* three-layer prefix so the initial assembly and the retry path consume the
* same returned list. This test pins that contract: the prefix list
* contains all three layers and the wiki layer reflects what
* {@link WikiContextService#buildRelevantContext} returned.
*/
class ReasoningNodePtlPromptTest {
private static final String WIKI_RELEVANT_TEXT =
"[Wiki Relevant Pages]\n- module-X.md (matches 'investigate'): ...";
@Test
void prefixIncludesSystemRuntimeAndWikiSegments() {
WikiContextService wikiContextService = mock(WikiContextService.class);
when(wikiContextService.buildRelevantContext(eq(42L), anyString()))
.thenReturn(WIKI_RELEVANT_TEXT);
ReasoningNode node = newNode(wikiContextService);
List<Message> prefix = node.buildNonHistoryPrefix(
"you are a helpful assistant",
"/workspace/active",
"42",
"investigate the bug in module X");
// Three layers: System, runtime-context UserMessage, wiki UserMessage.
assertThat(prefix).hasSize(3);
assertThat(prefix.get(0)).isInstanceOf(SystemMessage.class);
assertThat(prefix.get(0).getText()).contains("you are a helpful assistant");
assertThat(prefix.get(1)).isInstanceOf(UserMessage.class);
assertThat(prefix.get(2)).isInstanceOf(UserMessage.class);
assertThat(prefix.get(2).getText()).isEqualTo(WIKI_RELEVANT_TEXT);
}
@Test
void buildIsDeterministicAcrossCalls_soInitialAndRetryShareIdenticalLayout() {
// Critical regression invariant: both Prompt assemblies (initial and
// PTL retry) consume the SAME list reference, so the wiki segment
// can never diverge between them. Belt-and-suspenders, also verify
// that two independent calls with the same inputs produce
// structurally identical output.
WikiContextService wikiContextService = mock(WikiContextService.class);
when(wikiContextService.buildRelevantContext(eq(42L), anyString()))
.thenReturn(WIKI_RELEVANT_TEXT);
ReasoningNode node = newNode(wikiContextService);
List<Message> a = node.buildNonHistoryPrefix(
"sys", "/workspace", "42", "goal");
List<Message> b = node.buildNonHistoryPrefix(
"sys", "/workspace", "42", "goal");
assertThat(a).hasSameSizeAs(b);
for (int i = 0; i < a.size(); i++) {
assertThat(a.get(i).getClass()).isEqualTo(b.get(i).getClass());
assertThat(a.get(i).getText()).isEqualTo(b.get(i).getText());
}
}
@Test
void noWikiServiceWiredSkipsWikiSegment() {
// wikiContextService is optional when null (e.g. minimal config or
// a test rig), the prefix should still be valid: just system +
// runtime context, no wiki layer.
ReasoningNode node = newNode(null);
List<Message> prefix = node.buildNonHistoryPrefix(
"you are a helpful assistant",
"/workspace/active",
"42",
"investigate the bug in module X");
assertThat(prefix).hasSize(2);
assertThat(prefix.get(0)).isInstanceOf(SystemMessage.class);
assertThat(prefix.get(1)).isInstanceOf(UserMessage.class);
}
@Test
void nonNumericAgentIdSkipsWikiSegment() {
WikiContextService wikiContextService = mock(WikiContextService.class);
ReasoningNode node = newNode(wikiContextService);
List<Message> prefix = node.buildNonHistoryPrefix(
"sys", "/workspace", "not-a-number", "goal");
// Non-numeric agentId is the contract carried over from the
// pre-refactor codebase skip wiki injection rather than throwing.
assertThat(prefix).hasSize(2);
verify(wikiContextService,
org.mockito.Mockito.never()).buildRelevantContext(
org.mockito.ArgumentMatchers.anyLong(), anyString());
}
@Test
void blankWikiResultSkipsWikiSegment() {
WikiContextService wikiContextService = mock(WikiContextService.class);
when(wikiContextService.buildRelevantContext(eq(42L), anyString()))
.thenReturn(" "); // blank drop the layer
ReasoningNode node = newNode(wikiContextService);
List<Message> prefix = node.buildNonHistoryPrefix(
"sys", "/workspace", "42", "goal");
assertThat(prefix).hasSize(2);
}
private static ReasoningNode newNode(WikiContextService wikiContextService) {
// 9-arg constructor explicit supportsReasoningEffort + empty
// tool set, nulls for the streaming / conversation-window deps we
// don't exercise here.
AgentToolSet emptyTools = AgentToolSet.fromCallbacks(List.of(), List.of());
return new ReasoningNode(
/* chatModel */ null,
/* toolSet */ emptyTools,
/* reasoningEffort */ null,
/* supportsReasoningEffort */ false,
/* streamingHelper */ null,
/* conversationWindowManager */ null,
/* streamTracker */ null,
/* maxOutputTokens */ 1024,
wikiContextService);
}
}