mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 06:01:49 +08:00
fix(agent): unify context assembly and preserve compacted history
This commit is contained in:
parent
2d045b252e
commit
e46f176697
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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<Message> promptMessages = new ArrayList<>(nonHistoryPrefix);
|
||||
promptMessages.addAll(messages);
|
||||
|
||||
Prompt prompt = new Prompt(promptMessages, options);
|
||||
messages = assembledContext.history();
|
||||
List<Message> 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<Message> 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<Message> 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<Message> 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<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);
|
||||
var nudgeContext = CONTEXT_ASSEMBLER.assemble(nudgePrefix, messages, options,
|
||||
loopContextWindowTokens(), outputReserveTokens);
|
||||
List<Message> nudgeHistory = nudgeContext.history();
|
||||
List<Message> 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<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,
|
||||
NodeStreamingChatHelper.StreamResult result) {
|
||||
var output=reasonOutput().needsToolCall(false).shouldSummarize(false).continueReasoning(false)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<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 {
|
||||
when(helper.streamCallBufferedContent(any(),any(),anyString(),anyString())).thenReturn(
|
||||
response("","",NodeStreamingChatHelper.ErrorType.NONE,1),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user