mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11: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.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)
|
||||||
|
|||||||
@ -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));
|
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),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user