fix(agent): unify context assembly and preserve compacted history

This commit is contained in:
matevip 2026-10-30 07:45:50 -04:00
parent 2d045b252e
commit e46f176697
4 changed files with 255 additions and 112 deletions

View File

@ -0,0 +1,101 @@
package vip.mate.agent.context;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import java.util.ArrayList;
import java.util.List;
/**
* Single budgeting boundary for reasoning and recovery prompts. Protected prefix blocks are
* never trimmed; history selection and tool-pair integrity remain owned by LoopMessageBudgeter.
* This class does not invoke a model or execute tools.
*/
public final class ModelContextAssembler {
private static final int KEEP_RECENT_TOOL_RESPONSES = 3;
private final LoopMessageBudgeter budgeter = new LoopMessageBudgeter();
public record Assembly(Prompt prompt, List<Message> history,
LoopMessageBudgeter.BudgetTrace trace, int reservedTokens,
long estimatedTokens, boolean overflow, boolean mandatoryOverflow) {
/** Summarization may reduce content without changing the number of messages. */
public boolean reduces(Assembly original) {
return estimatedTokens < original.estimatedTokens;
}
}
public List<Message> prepareHistory(List<Message> history, ConversationWindowManager manager,
String conversationId, String workspaceBasePath) {
if (manager == null) return history;
List<Message> aged = manager.compactAgedToolResponses(history, KEEP_RECENT_TOOL_RESPONSES);
return manager.pruneOldToolResultsForModelInput(aged, conversationId, workspaceBasePath);
}
public Assembly assemble(List<Message> prefix, List<Message> history, ChatOptions options,
int contextWindowTokens, int outputReserveTokens) {
int reserved = Math.toIntExact(PromptBudget.estimateTokens(
new Prompt(prefix, options), null, outputReserveTokens));
int protectedTokens = protectedHistoryTokens(history);
LoopBudgetConfig config = LoopBudgetConfig.forContext(contextWindowTokens)
.withReservedPrefixTokens(reserved)
.withAvailableHistoryTokens(Math.max(0, contextWindowTokens - reserved - protectedTokens));
var budgeted = budgeter.budget(history, config);
return finish(prefix, budgeted.messages(), options, contextWindowTokens,
outputReserveTokens, reserved, budgeted.trace());
}
private Assembly finish(List<Message> prefix, List<Message> history, ChatOptions options,
int contextWindowTokens, int outputReserveTokens, int reserved,
LoopMessageBudgeter.BudgetTrace trace) {
List<Message> selected = List.copyOf(history);
List<Message> instructions = new ArrayList<>(prefix);
instructions.addAll(selected);
Prompt prompt = new Prompt(instructions, options);
long estimate = PromptBudget.estimateTokens(prompt, null, outputReserveTokens);
return new Assembly(prompt, selected, trace, reserved, estimate,
estimate > contextWindowTokens,
(long) reserved + protectedHistoryTokens(selected) > contextWindowTokens);
}
public Assembly assembleRetry(List<Message> prefix, List<Message> compacted,
UserMessage originalGoal, ChatOptions options,
int contextWindowTokens, int outputReserveTokens) {
List<Message> restored = new ArrayList<>(compacted);
if (originalGoal != null && restored.stream().noneMatch(message -> message instanceof UserMessage
&& message.getText().equals(originalGoal.getText()))) {
int insertion = 0;
for (int i = 0; i < restored.size(); i++) {
if (restored.get(i) instanceof UserMessage) insertion = i + 1;
}
while (insertion < restored.size() && restored.get(insertion) instanceof SystemMessage) insertion++;
restored.add(insertion, originalGoal);
}
// A structured summary is already a deliberate compression result. Running the tail
// selector again could erase it, especially when the summary is a UserMessage.
// Preserve it intact and let the caller reject an oversized assembled request.
int reserved = Math.toIntExact(PromptBudget.estimateTokens(
new Prompt(prefix, options), null, outputReserveTokens));
var trace = LoopMessageBudgeter.BudgetTrace.untouched(restored.size(),
TokenEstimator.estimateTokens(restored), reserved, 0);
return finish(prefix, restored, options, contextWindowTokens, outputReserveTokens, reserved, trace);
}
public static UserMessage latestUser(List<Message> history) {
for (int i = history.size() - 1; i >= 0; i--) {
if (history.get(i) instanceof UserMessage user) return user;
}
return null;
}
private static int protectedHistoryTokens(List<Message> history) {
int tokens = 0;
for (Message message : history) {
if (!(message instanceof SystemMessage)) break;
tokens += TokenEstimator.estimateTokens(message);
}
return tokens + TokenEstimator.estimateTokens(latestUser(history));
}
}

View File

@ -25,7 +25,7 @@ import vip.mate.agent.GraphEventPublisher;
import vip.mate.llm.chatmodel.ThinkingLevelHolder; import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.agent.graph.NodeStreamingChatHelper; import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.context.ConversationWindowManager; import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.context.LoopBudgetConfig; import vip.mate.agent.context.ModelContextAssembler;
import vip.mate.agent.context.PrefixBudgetPlan; import vip.mate.agent.context.PrefixBudgetPlan;
import vip.mate.agent.context.LoopMessageBudgeter; import vip.mate.agent.context.LoopMessageBudgeter;
import vip.mate.agent.context.RuntimeContextInjector; import vip.mate.agent.context.RuntimeContextInjector;
@ -88,14 +88,8 @@ public class ReasoningNode implements NodeAction {
*/ */
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 16384; private static final int DEFAULT_MAX_OUTPUT_TOKENS = 16384;
/** /** Shared stateless context assembly for initial reasoning and recovery calls. */
* Stateless singleton used to budget the per-iteration working message private static final ModelContextAssembler CONTEXT_ASSEMBLER = new ModelContextAssembler();
* list. Static-final because the budgeter holds no mutable state — the
* choice keeps the existing ReasoningNode constructor surface unchanged
* (it already carries 13 parameters across 5 overloads) and makes the
* dependency obvious to anyone reading the class.
*/
private static final LoopMessageBudgeter LOOP_BUDGETER = new LoopMessageBudgeter();
/** /**
* Fallback context window used when no provider-level value is wired in. * Fallback context window used when no provider-level value is wired in.
@ -125,17 +119,6 @@ public class ReasoningNode implements NodeAction {
*/ */
private static final int MAX_EMPTY_COMPLETION_RETRIES = 2; 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;
private static final int LONG_FORM_MIN_REQUEST_CHARS = 3_000; private static final int LONG_FORM_MIN_REQUEST_CHARS = 3_000;
private static final Pattern ARABIC_CHAR_COUNT_PATTERN = Pattern.compile( private static final Pattern ARABIC_CHAR_COUNT_PATTERN = Pattern.compile(
"(\\d{1,3}(?:[,,]\\d{3})+|\\d+(?:\\.\\d+)?)\\s*(万|千|k|K)?\\s*(字|字符|中文字|汉字|word|words)"); "(\\d{1,3}(?:[,,]\\d{3})+|\\d+(?:\\.\\d+)?)\\s*(万|千|k|K)?\\s*(字|字符|中文字|汉字|word|words)");
@ -968,23 +951,8 @@ public class ReasoningNode implements NodeAction {
nonHistoryPrefix.add(new SystemMessage(executionFacts)); nonHistoryPrefix.add(new SystemMessage(executionFacts));
} }
if (conversationWindowManager != null) { messages = CONTEXT_ASSEMBLER.prepareHistory(messages, conversationWindowManager,
// Age-based compaction first: drop the body of tool responses conversationId, workspaceBasePath);
// 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
// being rewritten into a lossy single-line summary.
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
messages, conversationId, workspaceBasePath);
}
// 请求级思考深度覆盖(ThinkingLevelHolder 由 AgentService 设置) // 请求级思考深度覆盖(ThinkingLevelHolder 由 AgentService 设置)
String effectiveReasoning = resolveEffectiveReasoningEffort(); String effectiveReasoning = resolveEffectiveReasoningEffort();
@ -1004,24 +972,13 @@ public class ReasoningNode implements NodeAction {
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks); ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
int outputReserveTokens = outputReserveTokens(options, effectiveMaxOutputTokens()); int outputReserveTokens = outputReserveTokens(options, effectiveMaxOutputTokens());
// Reserve exactly the prefix and schemas assembled for this call, plus actual output once. var assembledContext = CONTEXT_ASSEMBLER.assemble(nonHistoryPrefix, messages, options,
int loopReservedPrefixTokens = TokenEstimator.estimateTokens(nonHistoryPrefix) loopContextWindowTokens(), outputReserveTokens);
+ TokenEstimator.estimateToolsTokens(activeCallbacks) + outputReserveTokens;
LoopBudgetConfig loopCfg = LoopBudgetConfig.forContext(loopContextWindowTokens())
.withReservedPrefixTokens(loopReservedPrefixTokens);
// Leave room for the protected history head and latest user anchor.
// Ordinary history is clipped below the default policy floor when
// needed. An indivisible latest tool pair is checked before sending.
int protectedHistoryTokens = protectedHistoryTokens(messages);
int availableHistoryTokens = Math.max(0,
loopContextWindowTokens() - loopReservedPrefixTokens - protectedHistoryTokens);
loopCfg = loopCfg.withAvailableHistoryTokens(availableHistoryTokens);
LoopMessageBudgeter.Result budgeted = LOOP_BUDGETER.budget(messages, loopCfg);
// Only log when the budget actually modified the list — a triggered- // Only log when the budget actually modified the list — a triggered-
// but-no-op pass is normal (history fits comfortably under the tail // but-no-op pass is normal (history fits comfortably under the tail
// budget) and would otherwise spam logs every iteration. // budget) and would otherwise spam logs every iteration.
if (budgeted.trace().modified()) { if (assembledContext.trace().modified()) {
LoopMessageBudgeter.BudgetTrace t = budgeted.trace(); LoopMessageBudgeter.BudgetTrace t = assembledContext.trace();
log.warn("[ReasoningNode] Loop budget trim: {} -> {} msgs (history {} -> {} tokens, " log.warn("[ReasoningNode] Loop budget trim: {} -> {} msgs (history {} -> {} tokens, "
+ "prefix~{}), head={}, tail={}, droppedMiddle={}, orphans={}, " + "prefix~{}), head={}, tail={}, droppedMiddle={}, orphans={}, "
+ "anchorEnforced={}, anchorStitched={}, targetMaxTripped={}, " + "anchorEnforced={}, anchorStitched={}, targetMaxTripped={}, "
@ -1032,11 +989,9 @@ public class ReasoningNode implements NodeAction {
t.anchorEnforced(), t.anchorStitched(), t.targetMaxTripped(), t.anchorEnforced(), t.anchorStitched(), t.targetMaxTripped(),
t.capExceededForPairIntegrity(), t.minTailFloorApplied(), conversationId); t.capExceededForPairIntegrity(), t.minTailFloorApplied(), conversationId);
} }
messages = budgeted.messages(); messages = assembledContext.history();
List<Message> promptMessages = new ArrayList<>(nonHistoryPrefix); List<Message> promptMessages = assembledContext.prompt().getInstructions();
promptMessages.addAll(messages); Prompt prompt = assembledContext.prompt();
Prompt prompt = new Prompt(promptMessages, options);
// Prefix accounting: how much of the window the never-trimmed prefix // Prefix accounting: how much of the window the never-trimmed prefix
// (system prompt + runtime context + wiki + skill catalog + ledger) // (system prompt + runtime context + wiki + skill catalog + ledger)
@ -1055,11 +1010,10 @@ public class ReasoningNode implements NodeAction {
// tool pairs which can exceed a tail allowance. Reject known estimated // tool pairs which can exceed a tail allowance. Reject known estimated
// overflow before the provider or a futile compaction retry. Disabled // overflow before the provider or a futile compaction retry. Disabled
// budgeting retains legacy behavior. // budgeting retains legacy behavior.
boolean mandatoryPrefixOverflow = loopReservedPrefixTokens + protectedHistoryTokens boolean mandatoryPrefixOverflow = assembledContext.mandatoryOverflow();
> loopContextWindowTokens(); long assembledPromptEstimateTokens = assembledContext.estimatedTokens();
long assembledPromptEstimateTokens = vip.mate.agent.context.PromptBudget.estimateTokens(prompt, null, outputReserveTokens);
boolean promptOverflow = (holdContent || prefixBudgetPlan != null && prefixBudgetPlan.enabled()) boolean promptOverflow = (holdContent || prefixBudgetPlan != null && prefixBudgetPlan.enabled())
&& assembledPromptEstimateTokens > loopContextWindowTokens(); && assembledContext.overflow();
boolean localPromptRejected = promptOverflow; boolean localPromptRejected = promptOverflow;
// ======= LLM 调用区域 ======= // ======= LLM 调用区域 =======
@ -1174,18 +1128,15 @@ public class ReasoningNode implements NodeAction {
NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0)); NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0));
compactedMessages = null; compactedMessages = null;
} }
if (compactedMessages != null) { var retryContext = compactedMessages == null ? null : CONTEXT_ASSEMBLER.assembleRetry(
compactedMessages = restoreOriginalAnchor(compactedMessages, latestUser(messages)); nonHistoryPrefix, compactedMessages, ModelContextAssembler.latestUser(messages), options,
} loopContextWindowTokens(), outputReserveTokens);
if (retryContext != null && retryContext.reduces(assembledContext)) {
if (compactedMessages != null && compactedMessages.size() < messages.size()) { compactedMessages = retryContext.history();
// Reuse the SAME non-history prefix — wiki/runtime context preserved. List<Message> retryPromptMessages = retryContext.prompt().getInstructions();
List<Message> retryPromptMessages = new ArrayList<>(nonHistoryPrefix); long retryEstimateTokens = retryContext.estimatedTokens();
retryPromptMessages.addAll(compactedMessages);
long retryEstimateTokens = vip.mate.agent.context.PromptBudget.estimateTokens(
new Prompt(retryPromptMessages, options), null, outputReserveTokens);
if ((holdContent || prefixBudgetPlan != null && prefixBudgetPlan.enabled()) if ((holdContent || prefixBudgetPlan != null && prefixBudgetPlan.enabled())
&& retryEstimateTokens > loopContextWindowTokens()) { && retryContext.overflow()) {
localPromptRejected = true; localPromptRejected = true;
String retryOverflowMessage = "压缩重试 Prompt 估算 " + retryEstimateTokens String retryOverflowMessage = "压缩重试 Prompt 估算 " + retryEstimateTokens
+ " tokens 仍超过上下文窗口 " + loopContextWindowTokens() + " tokens 仍超过上下文窗口 " + loopContextWindowTokens()
@ -1195,7 +1146,7 @@ public class ReasoningNode implements NodeAction {
0, 0, false, retryOverflowMessage, 0, 0, false, retryOverflowMessage,
NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0)); NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0));
} else { } else {
Prompt retryPrompt = new Prompt(retryPromptMessages, options); Prompt retryPrompt = retryContext.prompt();
log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages", log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages",
messages.size(), compactedMessages.size()); messages.size(), compactedMessages.size());
nextLlmCallCount++; nextLlmCallCount++;
@ -1213,7 +1164,7 @@ public class ReasoningNode implements NodeAction {
promptMessages = retryPromptMessages; promptMessages = retryPromptMessages;
} }
} else { } else {
log.warn("[ReasoningNode] Compaction did not reduce messages, cannot retry"); log.warn("[ReasoningNode] Compaction did not reduce estimated request tokens, cannot retry");
} }
} }
@ -1258,19 +1209,13 @@ public class ReasoningNode implements NodeAction {
// objective which would replace the protected original anchor. // objective which would replace the protected original anchor.
List<Message> nudgePrefix = new ArrayList<>(nonHistoryPrefix); List<Message> nudgePrefix = new ArrayList<>(nonHistoryPrefix);
nudgePrefix.add(new SystemMessage(nudge)); nudgePrefix.add(new SystemMessage(nudge));
int nudgeReserved = TokenEstimator.estimateTokens(nudgePrefix) var nudgeContext = CONTEXT_ASSEMBLER.assemble(nudgePrefix, messages, options,
+ toolSchemaEstimateTokens + outputReserveTokens; loopContextWindowTokens(), outputReserveTokens);
LoopBudgetConfig nudgeConfig = LoopBudgetConfig.forContext(loopContextWindowTokens()) List<Message> nudgeHistory = nudgeContext.history();
.withReservedPrefixTokens(nudgeReserved) List<Message> nudgedMessages = nudgeContext.prompt().getInstructions();
.withAvailableHistoryTokens(Math.max(0, loopContextWindowTokens() long nudgeEstimate = nudgeContext.estimatedTokens();
- nudgeReserved - protectedHistoryTokens(messages)));
List<Message> nudgeHistory = LOOP_BUDGETER.budget(messages, nudgeConfig).messages();
List<Message> nudgedMessages = new ArrayList<>(nudgePrefix);
nudgedMessages.addAll(nudgeHistory);
long nudgeEstimate = vip.mate.agent.context.PromptBudget.estimateTokens(
new Prompt(nudgedMessages, options), null, outputReserveTokens);
if ((holdContent || prefixBudgetPlan != null && prefixBudgetPlan.enabled()) if ((holdContent || prefixBudgetPlan != null && prefixBudgetPlan.enabled())
&& nudgeEstimate > loopContextWindowTokens()) { && nudgeContext.overflow()) {
localPromptRejected = true; localPromptRejected = true;
result = mergeCandidateUsage(result, new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false, result = mergeCandidateUsage(result, new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false,
0, 0, false, "空回复重试 Prompt 估算 " + nudgeEstimate 0, 0, false, "空回复重试 Prompt 估算 " + nudgeEstimate
@ -1278,7 +1223,7 @@ public class ReasoningNode implements NodeAction {
NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0)); NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0));
break; break;
} }
Prompt nudgePrompt = new Prompt(nudgedMessages, options); Prompt nudgePrompt = nudgeContext.prompt();
nextLlmCallCount++; nextLlmCallCount++;
result = mergeCandidateUsage(result, callCandidate(holdContent, nudgePrompt, conversationId, "reasoning_empty_retry")); result = mergeCandidateUsage(result, callCandidate(holdContent, nudgePrompt, conversationId, "reasoning_empty_retry"));
messages = nudgeHistory; messages = nudgeHistory;
@ -1785,31 +1730,6 @@ public class ReasoningNode implements NodeAction {
return maxIterations <= 0 ? Long.MAX_VALUE : Math.max(0, (long)maxIterations * 5L - usedCalls); return maxIterations <= 0 ? Long.MAX_VALUE : Math.max(0, (long)maxIterations * 5L - usedCalls);
} }
private static UserMessage latestUser(List<Message> messages) {
for (int i=messages.size()-1;i>=0;i--) if (messages.get(i) instanceof UserMessage user) return user;
return null;
}
private static int protectedHistoryTokens(List<Message> messages) {
int tokens=0;
for (Message message:messages) {
if (!(message instanceof SystemMessage)) break;
tokens += TokenEstimator.estimateTokens(message);
}
return tokens + TokenEstimator.estimateTokens(latestUser(messages));
}
private static List<Message> restoreOriginalAnchor(List<Message> compacted, UserMessage original) {
if (original == null || compacted.stream().anyMatch(message -> message instanceof UserMessage
&& message.getText().equals(original.getText()))) return compacted;
List<Message> restored = new ArrayList<>(compacted);
int insertion=0;
for (int i=0;i<restored.size();i++) if (restored.get(i) instanceof UserMessage) insertion=i+1;
while (insertion<restored.size() && restored.get(insertion) instanceof SystemMessage) insertion++;
restored.add(insertion,original);
return restored;
}
private static Map<String,Object> retryBudgetStop(RunState state, int calls, private static Map<String,Object> retryBudgetStop(RunState state, int calls,
NodeStreamingChatHelper.StreamResult result) { NodeStreamingChatHelper.StreamResult result) {
var output=reasonOutput().needsToolCall(false).shouldSummarize(false).continueReasoning(false) var output=reasonOutput().needsToolCall(false).shouldSummarize(false).continueReasoning(false)

View File

@ -0,0 +1,108 @@
package vip.mate.agent.context;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.*;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.tool.ToolCallback;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class ModelContextAssemblerTest {
private final ModelContextAssembler assembler = new ModelContextAssembler();
private final ChatOptions options = ChatOptions.builder().maxTokens(512).build();
@Test
void accountsForPrefixSchemasAndOutputExactlyOnce() {
ToolCallback tool = mock(ToolCallback.class);
var definition = org.springframework.ai.tool.definition.ToolDefinition.builder()
.name("lookup").description("lookup record").inputSchema("{\"type\":\"object\"}").build();
when(tool.getToolDefinition()).thenReturn(definition);
ChatOptions tools = ToolCallingChatOptions.builder().maxTokens(512).toolCallbacks(tool).build();
List<Message> prefix = List.of(new SystemMessage("skill constraints; successful action receipts"));
List<Message> history = List.of(new UserMessage("original objective"));
var result = assembler.assemble(prefix, history, tools, 8192, 512);
assertThat(result.prompt().getInstructions()).containsExactlyElementsOf(
List.of(prefix.getFirst(), history.getFirst()));
assertThat(result.estimatedTokens()).isEqualTo(PromptBudget.estimateTokens(result.prompt(), null, 512));
assertThat(result.reservedTokens()).isEqualTo(TokenEstimator.estimateTokens(prefix)
+ TokenEstimator.estimateToolsTokens(List.of(tool)) + 512);
assertThat(result.overflow()).isFalse();
}
@Test
void retryRestoresOriginalObjectiveAndKeepsAllProtectedPrefixBlocks() {
UserMessage objective = new UserMessage("finish original objective");
List<Message> prefix = List.of(new SystemMessage("skill constraints"), new SystemMessage("execution receipts"));
var result = assembler.assembleRetry(prefix, List.of(new SystemMessage("summary"),
new AssistantMessage("working")), objective, options, 8192, 512);
assertThat(result.history()).contains(objective);
assertThat(result.prompt().getInstructions()).startsWith(prefix.toArray(Message[]::new));
var repeated = assembler.assembleRetry(prefix, result.history(), objective, options, 8192, 512);
assertThat(repeated.history().stream().filter(objective::equals).count()).isEqualTo(1);
}
@Test
void rejectsImpossibleWindowWithoutDeletingGoalOrSplittingLatestToolPair() {
UserMessage goal = new UserMessage("preserve goal");
var call = AssistantMessage.builder().content("").toolCalls(List.of(
new AssistantMessage.ToolCall("c1", "function", "lookup", "{}"))).build();
var response = ToolResponseMessage.builder().responses(List.of(
new ToolResponseMessage.ToolResponse("c1", "lookup", "result".repeat(500)))).build();
var result = assembler.assemble(List.of(new SystemMessage("mandatory".repeat(1000))),
List.of(goal, call, response), options, 1024, 512);
assertThat(result.overflow()).isTrue();
assertThat(result.history()).contains(goal, call, response);
assertThat(result.mandatoryOverflow()).isTrue();
}
@Test
void smallerRetryWithSameMessageCountIsUseful() {
UserMessage goal = new UserMessage("original goal");
var original = assembler.assemble(List.of(), List.of(goal,
new AssistantMessage("large history ".repeat(100))), options, 32000, 512);
var retry = assembler.assembleRetry(List.of(), List.of(goal,
new AssistantMessage("summary")), goal, options, 32000, 512);
assertThat(retry.history()).hasSameSizeAs(original.history());
assertThat(retry.reduces(original)).isTrue();
assertThat(original.reduces(retry)).isFalse();
}
@Test
void retryDoesNotDiscardFreshSummaryEvenWhenLoopTailPolicyWouldTrimIt() {
UserMessage goal = new UserMessage("original objective");
UserMessage summary = new UserMessage("completed actions and decisions ".repeat(100));
List<Message> compacted = new ArrayList<>();
compacted.add(summary);
compacted.add(goal);
for (int i = 0; i < 5; i++) compacted.add(new AssistantMessage("recent ".repeat(60)));
var retry = assembler.assembleRetry(List.of(new SystemMessage("prefix".repeat(600))),
compacted, goal, options, 4096, 512);
assertThat(retry.overflow()).isFalse();
assertThat(retry.history()).containsExactlyElementsOf(compacted);
}
@Test
void oversizedRetryRetainsSummaryAndReportsOverflowInsteadOfDiscardingIt() {
UserMessage goal = new UserMessage("original objective");
UserMessage summary = new UserMessage("界".repeat(10000));
var retry = assembler.assembleRetry(List.of(), List.of(summary), goal, options, 4096, 512);
assertThat(retry.history()).contains(summary, goal);
assertThat(retry.overflow()).isTrue();
}
@Test
void preparationReusesAgeAndSpillPoliciesWithoutMutatingInput() {
ConversationWindowManager manager = mock(ConversationWindowManager.class);
List<Message> original = new ArrayList<>(List.of(new UserMessage("goal")));
List<Message> aged = List.of(new UserMessage("aged"));
List<Message> pruned = List.of(new UserMessage("pruned"));
when(manager.compactAgedToolResponses(original, 3)).thenReturn(aged);
when(manager.pruneOldToolResultsForModelInput(aged, "conv", "/workspace")).thenReturn(pruned);
assertThat(assembler.prepareHistory(original, manager, "conv", "/workspace")).isEqualTo(pruned);
assertThat(original).hasSize(1);
assertThat(assembler.prepareHistory(original, null, "conv", "/workspace")).isEqualTo(original);
}
}

View File

@ -78,6 +78,20 @@ class ReasoningNodeRetryUsageTest {
response("","",NodeStreamingChatHelper.ErrorType.NONE,2),answer(4)); response("","",NodeStreamingChatHelper.ErrorType.NONE,2),answer(4));
var output=node(window).apply(state());usage(output,15,11);assertEquals("normal",output.get(FINISH_REASON)); var output=node(window).apply(state());usage(output,15,11);assertEquals("normal",output.get(FINISH_REASON));
} }
@Test void sameCountCompactionRetriesWhenTokenEstimateDrops() throws Exception {
var window = window();
when(window.compactForRetry(anyList(), any(), anyString(), any())).thenAnswer(call -> {
List<Message> history = call.getArgument(0);
return history.stream().map(message -> message instanceof AssistantMessage
? (Message) new AssistantMessage("compact") : message).toList();
});
when(helper.streamCallBufferedContent(any(), any(), anyString(), anyString())).thenReturn(
response(null, "", NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, 1), answer(2));
var output = node(window).apply(state());
usage(output, 11, 10);
assertEquals("normal", output.get(FINISH_REASON));
verify(helper).streamCallBufferedContent(any(), any(), anyString(), eq("reasoning_compact_retry"));
}
@Test void cancellationAfterMultipleReturnedCandidatesRetainsTheirUsage() throws Exception { @Test void cancellationAfterMultipleReturnedCandidatesRetainsTheirUsage() throws Exception {
when(helper.streamCallBufferedContent(any(),any(),anyString(),anyString())).thenReturn( when(helper.streamCallBufferedContent(any(),any(),anyString(),anyString())).thenReturn(
response("","",NodeStreamingChatHelper.ErrorType.NONE,1), response("","",NodeStreamingChatHelper.ErrorType.NONE,1),