From e46f176697a9ebca89bb584ac8096bb0864adcad Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 30 Oct 2026 07:45:50 -0400 Subject: [PATCH] fix(agent): unify context assembly and preserve compacted history --- .../agent/context/ModelContextAssembler.java | 101 ++++++++++++ .../mate/agent/graph/node/ReasoningNode.java | 144 ++++-------------- .../context/ModelContextAssemblerTest.java | 108 +++++++++++++ .../node/ReasoningNodeRetryUsageTest.java | 14 ++ 4 files changed, 255 insertions(+), 112 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/context/ModelContextAssembler.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/context/ModelContextAssemblerTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ModelContextAssembler.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ModelContextAssembler.java new file mode 100644 index 00000000..bad385fa --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ModelContextAssembler.java @@ -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 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 prepareHistory(List history, ConversationWindowManager manager, + String conversationId, String workspaceBasePath) { + if (manager == null) return history; + List aged = manager.compactAgedToolResponses(history, KEEP_RECENT_TOOL_RESPONSES); + return manager.pruneOldToolResultsForModelInput(aged, conversationId, workspaceBasePath); + } + + public Assembly assemble(List prefix, List 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 prefix, List history, ChatOptions options, + int contextWindowTokens, int outputReserveTokens, int reserved, + LoopMessageBudgeter.BudgetTrace trace) { + List selected = List.copyOf(history); + List 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 prefix, List compacted, + UserMessage originalGoal, ChatOptions options, + int contextWindowTokens, int outputReserveTokens) { + List 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 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 history) { + int tokens = 0; + for (Message message : history) { + if (!(message instanceof SystemMessage)) break; + tokens += TokenEstimator.estimateTokens(message); + } + return tokens + TokenEstimator.estimateTokens(latestUser(history)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index be8a8b48..bc0b4f5c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -25,7 +25,7 @@ import vip.mate.agent.GraphEventPublisher; import vip.mate.llm.chatmodel.ThinkingLevelHolder; import vip.mate.agent.graph.NodeStreamingChatHelper; 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.LoopMessageBudgeter; import vip.mate.agent.context.RuntimeContextInjector; @@ -88,14 +88,8 @@ public class ReasoningNode implements NodeAction { */ private static final int DEFAULT_MAX_OUTPUT_TOKENS = 16384; - /** - * Stateless singleton used to budget the per-iteration working message - * 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(); + /** Shared stateless context assembly for initial reasoning and recovery calls. */ + private static final ModelContextAssembler CONTEXT_ASSEMBLER = new ModelContextAssembler(); /** * 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; - /** - * 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 Pattern ARABIC_CHAR_COUNT_PATTERN = Pattern.compile( "(\\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)); } - if (conversationWindowManager != null) { - // Age-based compaction first: drop the body of tool responses - // 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); - } + messages = CONTEXT_ASSEMBLER.prepareHistory(messages, conversationWindowManager, + conversationId, workspaceBasePath); // 请求级思考深度覆盖(ThinkingLevelHolder 由 AgentService 设置) String effectiveReasoning = resolveEffectiveReasoningEffort(); @@ -1004,24 +972,13 @@ public class ReasoningNode implements NodeAction { ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks); int outputReserveTokens = outputReserveTokens(options, effectiveMaxOutputTokens()); - // Reserve exactly the prefix and schemas assembled for this call, plus actual output once. - int loopReservedPrefixTokens = TokenEstimator.estimateTokens(nonHistoryPrefix) - + 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); + var assembledContext = CONTEXT_ASSEMBLER.assemble(nonHistoryPrefix, messages, options, + loopContextWindowTokens(), outputReserveTokens); // Only log when the budget actually modified the list — a triggered- // but-no-op pass is normal (history fits comfortably under the tail // budget) and would otherwise spam logs every iteration. - if (budgeted.trace().modified()) { - LoopMessageBudgeter.BudgetTrace t = budgeted.trace(); + if (assembledContext.trace().modified()) { + LoopMessageBudgeter.BudgetTrace t = assembledContext.trace(); log.warn("[ReasoningNode] Loop budget trim: {} -> {} msgs (history {} -> {} tokens, " + "prefix~{}), head={}, tail={}, droppedMiddle={}, orphans={}, " + "anchorEnforced={}, anchorStitched={}, targetMaxTripped={}, " @@ -1032,11 +989,9 @@ public class ReasoningNode implements NodeAction { t.anchorEnforced(), t.anchorStitched(), t.targetMaxTripped(), t.capExceededForPairIntegrity(), t.minTailFloorApplied(), conversationId); } - messages = budgeted.messages(); - List promptMessages = new ArrayList<>(nonHistoryPrefix); - promptMessages.addAll(messages); - - Prompt prompt = new Prompt(promptMessages, options); + messages = assembledContext.history(); + List promptMessages = assembledContext.prompt().getInstructions(); + Prompt prompt = assembledContext.prompt(); // Prefix accounting: how much of the window the never-trimmed prefix // (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 // overflow before the provider or a futile compaction retry. Disabled // budgeting retains legacy behavior. - boolean mandatoryPrefixOverflow = loopReservedPrefixTokens + protectedHistoryTokens - > loopContextWindowTokens(); - long assembledPromptEstimateTokens = vip.mate.agent.context.PromptBudget.estimateTokens(prompt, null, outputReserveTokens); + boolean mandatoryPrefixOverflow = assembledContext.mandatoryOverflow(); + long assembledPromptEstimateTokens = assembledContext.estimatedTokens(); boolean promptOverflow = (holdContent || prefixBudgetPlan != null && prefixBudgetPlan.enabled()) - && assembledPromptEstimateTokens > loopContextWindowTokens(); + && assembledContext.overflow(); boolean localPromptRejected = promptOverflow; // ======= LLM 调用区域 ======= @@ -1174,18 +1128,15 @@ public class ReasoningNode implements NodeAction { NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0)); compactedMessages = null; } - if (compactedMessages != null) { - compactedMessages = restoreOriginalAnchor(compactedMessages, latestUser(messages)); - } - - if (compactedMessages != null && compactedMessages.size() < messages.size()) { - // Reuse the SAME non-history prefix — wiki/runtime context preserved. - List retryPromptMessages = new ArrayList<>(nonHistoryPrefix); - retryPromptMessages.addAll(compactedMessages); - long retryEstimateTokens = vip.mate.agent.context.PromptBudget.estimateTokens( - new Prompt(retryPromptMessages, options), null, outputReserveTokens); + var retryContext = compactedMessages == null ? null : CONTEXT_ASSEMBLER.assembleRetry( + nonHistoryPrefix, compactedMessages, ModelContextAssembler.latestUser(messages), options, + loopContextWindowTokens(), outputReserveTokens); + if (retryContext != null && retryContext.reduces(assembledContext)) { + compactedMessages = retryContext.history(); + List retryPromptMessages = retryContext.prompt().getInstructions(); + long retryEstimateTokens = retryContext.estimatedTokens(); if ((holdContent || prefixBudgetPlan != null && prefixBudgetPlan.enabled()) - && retryEstimateTokens > loopContextWindowTokens()) { + && retryContext.overflow()) { localPromptRejected = true; String retryOverflowMessage = "压缩重试 Prompt 估算 " + retryEstimateTokens + " tokens 仍超过上下文窗口 " + loopContextWindowTokens() @@ -1195,7 +1146,7 @@ public class ReasoningNode implements NodeAction { 0, 0, false, retryOverflowMessage, NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0)); } else { - Prompt retryPrompt = new Prompt(retryPromptMessages, options); + Prompt retryPrompt = retryContext.prompt(); log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages", messages.size(), compactedMessages.size()); nextLlmCallCount++; @@ -1213,7 +1164,7 @@ public class ReasoningNode implements NodeAction { promptMessages = retryPromptMessages; } } 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. List nudgePrefix = new ArrayList<>(nonHistoryPrefix); nudgePrefix.add(new SystemMessage(nudge)); - int nudgeReserved = TokenEstimator.estimateTokens(nudgePrefix) - + toolSchemaEstimateTokens + outputReserveTokens; - LoopBudgetConfig nudgeConfig = LoopBudgetConfig.forContext(loopContextWindowTokens()) - .withReservedPrefixTokens(nudgeReserved) - .withAvailableHistoryTokens(Math.max(0, loopContextWindowTokens() - - nudgeReserved - protectedHistoryTokens(messages))); - List nudgeHistory = LOOP_BUDGETER.budget(messages, nudgeConfig).messages(); - List nudgedMessages = new ArrayList<>(nudgePrefix); - nudgedMessages.addAll(nudgeHistory); - long nudgeEstimate = vip.mate.agent.context.PromptBudget.estimateTokens( - new Prompt(nudgedMessages, options), null, outputReserveTokens); + var nudgeContext = CONTEXT_ASSEMBLER.assemble(nudgePrefix, messages, options, + loopContextWindowTokens(), outputReserveTokens); + List nudgeHistory = nudgeContext.history(); + List nudgedMessages = nudgeContext.prompt().getInstructions(); + long nudgeEstimate = nudgeContext.estimatedTokens(); if ((holdContent || prefixBudgetPlan != null && prefixBudgetPlan.enabled()) - && nudgeEstimate > loopContextWindowTokens()) { + && nudgeContext.overflow()) { localPromptRejected = true; result = mergeCandidateUsage(result, new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false, 0, 0, false, "空回复重试 Prompt 估算 " + nudgeEstimate @@ -1278,7 +1223,7 @@ public class ReasoningNode implements NodeAction { NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0)); break; } - Prompt nudgePrompt = new Prompt(nudgedMessages, options); + Prompt nudgePrompt = nudgeContext.prompt(); nextLlmCallCount++; result = mergeCandidateUsage(result, callCandidate(holdContent, nudgePrompt, conversationId, "reasoning_empty_retry")); messages = nudgeHistory; @@ -1785,31 +1730,6 @@ public class ReasoningNode implements NodeAction { return maxIterations <= 0 ? Long.MAX_VALUE : Math.max(0, (long)maxIterations * 5L - usedCalls); } - private static UserMessage latestUser(List 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 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 restoreOriginalAnchor(List compacted, UserMessage original) { - if (original == null || compacted.stream().anyMatch(message -> message instanceof UserMessage - && message.getText().equals(original.getText()))) return compacted; - List restored = new ArrayList<>(compacted); - int insertion=0; - for (int i=0;i retryBudgetStop(RunState state, int calls, NodeStreamingChatHelper.StreamResult result) { var output=reasonOutput().needsToolCall(false).shouldSummarize(false).continueReasoning(false) diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ModelContextAssemblerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ModelContextAssemblerTest.java new file mode 100644 index 00000000..5c72eb58 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ModelContextAssemblerTest.java @@ -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 prefix = List.of(new SystemMessage("skill constraints; successful action receipts")); + List 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 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 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 original = new ArrayList<>(List.of(new UserMessage("goal"))); + List aged = List.of(new UserMessage("aged")); + List 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); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeRetryUsageTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeRetryUsageTest.java index 941f2290..6fea93e1 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeRetryUsageTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeRetryUsageTest.java @@ -78,6 +78,20 @@ class ReasoningNodeRetryUsageTest { response("","",NodeStreamingChatHelper.ErrorType.NONE,2),answer(4)); 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 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 { when(helper.streamCallBufferedContent(any(),any(),anyString(),anyString())).thenReturn( response("","",NodeStreamingChatHelper.ErrorType.NONE,1),