From 507fceb2d0ccef1e1274a679ec0c0f2ffb1fcd8f Mon Sep 17 00:00:00 2001
From: matevip
Date: Wed, 14 Oct 2026 02:16:21 -0400
Subject: [PATCH] feat(agent): strengthen decision facts and long-task context
budgets
---
.../vip/mate/agent/AgentGraphBuilder.java | 6 +-
.../binding/service/AgentBindingService.java | 4 +-
.../context/BudgetedCompactionModel.java | 94 ++++++
.../mate/agent/context/LoopBudgetConfig.java | 39 ++-
.../agent/context/LoopMessageBudgeter.java | 20 +-
.../mate/agent/context/TokenEstimator.java | 17 +-
.../agent/graph/StateGraphReActAgent.java | 81 ++++-
.../agent/graph/edge/ReasoningDispatcher.java | 26 +-
.../graph/executor/ToolResultProperties.java | 2 +-
.../vip/mate/agent/graph/node/ActionNode.java | 2 +-
.../agent/graph/node/GoalEvaluationNode.java | 2 +-
.../agent/graph/node/LimitExceededNode.java | 46 ++-
.../mate/agent/graph/node/ReasoningNode.java | 307 ++++++++++++------
.../mate/agent/progress/ProgressLedger.java | 10 +-
.../progress/ProgressLedgerProjection.java | 107 ++++++
.../provider/RuleDecisionProvider.java | 62 +++-
.../java/vip/mate/goal/model/GoalEntity.java | 4 +-
.../goal/service/GoalDecisionAdapter.java | 20 +-
.../mate/goal/service/GoalDecisionFacts.java | 149 +++++++++
.../mate/tool/builtin/ProgressLedgerTool.java | 82 +++++
.../DefaultToolDisclosureService.java | 2 +-
.../main/resources/docs/en/decision-layer.md | 35 +-
.../main/resources/docs/zh/decision-layer.md | 32 +-
.../binding/AgentBindingServiceTest.java | 1 +
.../context/BudgetedCompactionModelTest.java | 53 +++
.../agent/context/LoopBudgetConfigTest.java | 31 ++
.../context/LoopMessageBudgeterTest.java | 24 +-
.../context/TokenEstimatorMessagesTest.java | 17 +
...StateGraphReActAgentInitialBudgetTest.java | 111 +++++++
.../ReasoningDispatcherLlmCallCountTest.java | 26 ++
.../node/ActionNodeAutoRecordSkipTest.java | 1 +
.../GoalEvaluationNodeContinuationTest.java | 11 +-
.../node/LimitExceededNodeProgressTest.java | 76 +++++
.../node/ReasoningNodeOutputClampTest.java | 8 +
.../node/ReasoningNodeProgressBudgetTest.java | 149 +++++++++
.../node/ReasoningNodeRetryBudgetTest.java | 202 ++++++++++++
.../ProgressLedgerProjectionTest.java | 50 +++
.../decision/RuleDecisionProviderTest.java | 159 +++++++++
.../mate/goal/model/GoalEntityBudgetTest.java | 25 ++
.../goal/service/GoalDecisionAdapterTest.java | 85 ++++-
.../tool/builtin/ProgressLedgerToolTest.java | 106 ++++++
.../disclosure/ToolDisclosureServiceTest.java | 4 +
42 files changed, 2112 insertions(+), 176 deletions(-)
create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/context/BudgetedCompactionModel.java
create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerProjection.java
create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/service/GoalDecisionFacts.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/context/BudgetedCompactionModelTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/context/LoopBudgetConfigTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/context/TokenEstimatorMessagesTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentInitialBudgetTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/node/LimitExceededNodeProgressTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeProgressBudgetTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeRetryBudgetTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerProjectionTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/decision/RuleDecisionProviderTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/goal/model/GoalEntityBudgetTest.java
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
index 6a51321f..a53da752 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -635,6 +635,7 @@ public class AgentGraphBuilder {
runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
StateGraphReActAgent agent = new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
chatModel, conversationWindowManager, toolSet);
+ agent.setPrefixBudgetPlan(prefixBudgetPlan);
if (reasoningRetentionProperties != null) {
agent.setPersistEveryIterationReasoning(reasoningRetentionProperties.persistsEveryIteration());
}
@@ -927,6 +928,7 @@ public class AgentGraphBuilder {
SummarizingNode summarizingNode = new SummarizingNode(chatModel, streamingHelper, streamTracker);
LimitExceededNode limitExceededNode = new LimitExceededNode(
chatModel, observationProcessor, streamingHelper, i18nService, progressLedgerService);
+ limitExceededNode.setPrefixBudgetPlan(prefixBudgetPlan);
FinalAnswerNode finalAnswerNode = new FinalAnswerNode(generatedFileCache, markdownNormalizeEnabled);
@@ -1511,9 +1513,9 @@ public class AgentGraphBuilder {
If you try to read a PDF/Office file with read_file, you will get binary garbage or an error.
## ProgressLedger Discipline (mandatory)
- The `## 当前任务进度` block injected near the top of every turn is the **authoritative record** of what you have done and what remains. Treat it as ground truth, not as a scratchpad you may ignore.
+ The `## 当前任务进度` block injected near the top of every turn preserves recorded step statuses across context trims. Model-authored `done` entries are progress claims, not proof of successful execution or acceptance; check actual results before claiming completion.
- **On starting any multi-step task** (≥3 tool calls expected), call `progress_update` in parallel batches of at most 16 calls to register every pending step BEFORE doing the work. Split larger ledgers across turns so the executor cap never drops entries.
- - **After each completed sub-step**, immediately call `progress_update` to flip its status to `done`. "Immediately" means in the same tool_calls batch that returns the result, not after the next reasoning turn.
+ - **After receiving and verifying a successful sub-step result**, call `progress_update` to flip that step to `done` on your next action. Never mark an operation `done` in the batch that starts it. You may batch the previous step's `done` update with the next independent step's tool call; each computation must still have its own tool call. Progress registration batches do not merge computational stages.
- **Never re-execute a step the ledger shows as `done`** unless you can articulate why the prior result is stale.
- **🔒 固定约束 entries** (pinned from skill manifests) are non-negotiable. They survive context compression for a reason — re-read them every turn and make sure your planned action still satisfies them.
- **🔧 自动记录 entries** are auto-filled by Java after each tool call. They are a safety net, not a substitute for your own `progress_update` — if you only rely on them, you will lose the pending/blocked view that drives planning.
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
index 663d7d25..8c030ee8 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
@@ -672,7 +672,7 @@ public class AgentBindingService implements AgentBindingResolver {
"getCurrentDate", "getCurrentDateTime", "getCurrentTime",
"setGoal", "addGoalCriterion", "completeGoal", "getGoalStatus",
"getManagedGoalJsonSlots", "publishManagedGoalJson", "checkManagedGoalJson",
- "waitForGoalInput", "resumeGoal", "progress_update",
+ "waitForGoalInput", "resumeGoal", "progress_update", "get_progress",
// These bridges resolve targets against the same effective allowlist.
"enable_tool", "tool_search", "tool_describe", "tool_call"
);
@@ -755,7 +755,7 @@ public class AgentBindingService implements AgentBindingResolver {
// tasks need it on every business agent, not just the planner,
// since context-window trims can otherwise let an agent forget
// what it has already produced and re-do work or stall.
- "progress_update",
+ "progress_update", "get_progress",
// Document / media generation — agent-wide capabilities, never
// declared inside any skill manifest. Pre-Phase-2b these were
// universally visible; the new gate silently strips them whenever
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/BudgetedCompactionModel.java b/mateclaw-server/src/main/java/vip/mate/agent/context/BudgetedCompactionModel.java
new file mode 100644
index 00000000..4ba2c631
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/BudgetedCompactionModel.java
@@ -0,0 +1,94 @@
+package vip.mate.agent.context;
+
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.prompt.ChatOptions;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.model.tool.ToolCallingChatOptions;
+import vip.mate.agent.runtime.state.RunState;
+import vip.mate.llm.chatmodel.ChatModelDefaults;
+import java.util.concurrent.CancellationException;
+import static vip.mate.agent.graph.state.MateClawStateKeys.*;
+
+/** Guards and counts actual compaction-model invocations, preserving a caller-reserved business call. */
+public final class BudgetedCompactionModel implements ChatModel {
+ public static final class CallBudgetExceeded extends RuntimeException {}
+ public static final class SummaryPromptOverflow extends RuntimeException {}
+ private final ChatModel delegate;
+ private final long allowedCalls;
+ private final int windowTokens;
+ private final int fallbackOutputTokens;
+ private volatile CancellationException cancellation;
+ private volatile String blockedPrompt;
+ private int calls;
+ private boolean callBudgetBlocked;
+ private final java.util.Map usage = new java.util.LinkedHashMap<>();
+ public CancellationException cancellation() { return cancellation; }
+ public String blockedPrompt() { return blockedPrompt; }
+ public boolean callBudgetBlocked() { return callBudgetBlocked; }
+ public synchronized java.util.Map usage() { return java.util.Map.copyOf(usage); }
+ public RunState withUsage(RunState state) {
+ var values = new java.util.LinkedHashMap<>(state.data());
+ usage().forEach((key,value) -> values.put(key,Math.addExact(state.value(key,0),value)));
+ return new RunState(values);
+ }
+ private synchronized void recordUsage(org.springframework.ai.chat.model.ChatResponse response) {
+ if (response == null || response.getMetadata() == null || response.getMetadata().getUsage() == null) return;
+ var value=response.getMetadata().getUsage();
+ addUsage(PROMPT_TOKENS,value.getPromptTokens());addUsage(COMPLETION_TOKENS,value.getCompletionTokens());
+ var nativeTokens=vip.mate.llm.cache.CacheUsageExtractor.extract(value);
+ addUsage(CACHE_READ_TOKENS,value.getCacheReadInputTokens()!=null ? Math.toIntExact(value.getCacheReadInputTokens()) : nativeTokens.cacheReadTokens());
+ addUsage(CACHE_WRITE_TOKENS,value.getCacheWriteInputTokens()!=null ? Math.toIntExact(value.getCacheWriteInputTokens()) : nativeTokens.cacheWriteTokens());
+ addUsage(REASONING_TOKENS,nativeTokens.reasoningTokens());
+ }
+ private void addUsage(String key,Integer value) {
+ if (value != null && value>0) usage.merge(key,value,Math::addExact);
+ }
+ public static int outputReserveTokens(ChatOptions options,int fallback) {
+ Integer maximum = options == null ? null : options.getMaxTokens();
+ if (options instanceof org.springframework.ai.openai.OpenAiChatOptions openAi
+ && openAi.getMaxCompletionTokens() != null) {
+ maximum = maximum == null ? openAi.getMaxCompletionTokens()
+ : Math.max(maximum,openAi.getMaxCompletionTokens());
+ }
+ return maximum == null ? Math.max(0,fallback) : Math.max(0,maximum);
+ }
+ public BudgetedCompactionModel(ChatModel delegate,long allowedCalls,int windowTokens,int fallbackOutputTokens) {
+ this.delegate=delegate; this.allowedCalls=allowedCalls;
+ this.windowTokens=windowTokens; this.fallbackOutputTokens=fallbackOutputTokens;
+ }
+ public synchronized int calls() { return calls; }
+ private synchronized void claim() {
+ if (calls >= allowedCalls) { callBudgetBlocked=true; throw new CallBudgetExceeded(); }
+ calls++;
+ }
+ private void checkPrompt(Prompt prompt) {
+ if (windowTokens <= 0) return;
+ ChatOptions defaults = ChatModelDefaults.optionsFor(delegate);
+ ChatOptions options = prompt.getOptions() != null ? prompt.getOptions() : defaults;
+ int output = outputReserveTokens(options, outputReserveTokens(defaults,fallbackOutputTokens));
+ int tools = options instanceof ToolCallingChatOptions toolOptions
+ ? TokenEstimator.estimateToolsTokens(toolOptions.getToolCallbacks()) : 0;
+ int total = TokenEstimator.estimateTokens(prompt.getInstructions()) + tools + output;
+ if (total > windowTokens) {
+ blockedPrompt = "摘要 Prompt 估算 " + total + " tokens 超过上下文窗口 " + windowTokens
+ + " tokens;未调用摘要模型。";
+ throw new SummaryPromptOverflow();
+ }
+ }
+ @Override public org.springframework.ai.chat.model.ChatResponse call(Prompt prompt) {
+ checkPrompt(prompt); claim();
+ try { var response=delegate.call(prompt);recordUsage(response);return response; }
+ catch (CancellationException stopped) { cancellation=stopped; throw stopped; }
+ }
+ @Override public reactor.core.publisher.Flux stream(Prompt prompt) {
+ return reactor.core.publisher.Flux.defer(() -> {
+ checkPrompt(prompt); claim();
+ var last=new java.util.concurrent.atomic.AtomicReference();
+ return delegate.stream(prompt).doOnNext(last::set)
+ .doOnError(CancellationException.class, stopped -> cancellation=stopped)
+ .doFinally(signal -> recordUsage(last.get()));
+ });
+ }
+ @Override public ChatOptions getDefaultOptions() { return ChatModelDefaults.optionsFor(delegate); }
+}
+
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/LoopBudgetConfig.java b/mateclaw-server/src/main/java/vip/mate/agent/context/LoopBudgetConfig.java
index 01228d6d..b62d9021 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/context/LoopBudgetConfig.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/LoopBudgetConfig.java
@@ -19,9 +19,8 @@ package vip.mate.agent.context;
* {@code keepTailTokens} — token budget reserved for the tail (recent
* observations + the current user message). Scales with the model
* window instead of relying on a fixed count.
- * {@code minTailMessages} — floor on the kept-tail count. Prevents a
- * single huge tool output from collapsing the tail to one message and
- * losing recent reasoning context.
+ * {@code minTailMessages} — preferred floor on the kept-tail count,
+ * applied only when those messages fit the tail soft ceiling.
* {@code tailSoftCeilingRatio} — multiplier applied to
* {@code keepTailTokens} when honoring the floor or pulling back to
* keep a tool pair whole. Lets the tail overshoot the hard budget by
@@ -48,8 +47,11 @@ public record LoopBudgetConfig(
/** Smallest useful trigger threshold; below this budgeting is effectively disabled. */
public static final int MIN_TRIGGER_TOKENS = 1_000;
- /** Smallest sensible tail budget; below this even one observation may not fit. */
- public static final int MIN_TAIL_TOKENS = 2_000;
+ /** Smallest representable explicit remaining-history budget. */
+ public static final int MIN_TAIL_TOKENS = 1;
+
+ /** Default policy floor; explicit available-window clipping may go lower. */
+ public static final int DEFAULT_MIN_TAIL_TOKENS = 2_000;
/** Floor on minTailMessages — fewer than 3 collapses recent context too aggressively. */
public static final int MIN_TAIL_MESSAGES_FLOOR = 3;
@@ -109,8 +111,8 @@ public record LoopBudgetConfig(
* trigger = 50% of the window — same threshold the multi-turn
* compressor uses, so the two layers stay calibrated.
* tail budget = 30% of the window.
- * minTailMessages = 4 — at least one full reasoning/action cycle
- * stays visible to the LLM no matter how big a single tool output is.
+ * minTailMessages = 4 — prefer a full recent reasoning/action cycle
+ * when it fits. Indivisible tool pairs are protected separately.
* tailSoftCeilingRatio = 1.5 — let the tail overshoot by 50% when
* enforcing the floor or pulling back to keep a tool pair whole.
* reservedPrefixTokens = 0 — caller should override with the real
@@ -124,14 +126,31 @@ public record LoopBudgetConfig(
if (contextWindowTokens <= 0) {
contextWindowTokens = 32_000;
}
- int trigger = Math.max(MIN_TRIGGER_TOKENS, (int) (contextWindowTokens * 0.50));
- int tail = Math.max(MIN_TAIL_TOKENS, (int) (contextWindowTokens * 0.30));
+ // Tiny reported windows still need a valid policy configuration so callers
+ // can reject an impossible protected prefix rather than fail in this constructor.
+ int trigger = Math.max(DEFAULT_MIN_TAIL_TOKENS + MIN_TRIGGER_TOKENS,
+ (int) (contextWindowTokens * 0.50));
+ int tail = Math.max(DEFAULT_MIN_TAIL_TOKENS, (int) (contextWindowTokens * 0.30));
if (tail >= trigger) {
- tail = Math.max(MIN_TAIL_TOKENS, trigger - MIN_TRIGGER_TOKENS);
+ tail = Math.max(DEFAULT_MIN_TAIL_TOKENS, trigger - MIN_TRIGGER_TOKENS);
}
return new LoopBudgetConfig(trigger, tail, 4, 1.5, 0, 200);
}
+ /**
+ * Clip the recent-history policy to the actual remaining window. The
+ * default 2000-token policy floor is not a permission to overflow it.
+ * An indivisible latest tool pair may still exceed this estimate; callers
+ * must reject that assembled prompt before contacting the provider.
+ */
+ public LoopBudgetConfig withAvailableHistoryTokens(int availableTokens) {
+ int tail = Math.min(keepTailTokens, Math.max(MIN_TAIL_TOKENS, availableTokens));
+ double ratio = Math.min(tailSoftCeilingRatio,
+ Math.max(1.0, (double)availableTokens / tail));
+ return new LoopBudgetConfig(triggerTokens, tail, minTailMessages,
+ ratio, reservedPrefixTokens, targetMaxMessages);
+ }
+
/** Return a copy with {@code reservedPrefixTokens} replaced. */
public LoopBudgetConfig withReservedPrefixTokens(int reservedPrefixTokens) {
return new LoopBudgetConfig(triggerTokens, keepTailTokens, minTailMessages,
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/LoopMessageBudgeter.java b/mateclaw-server/src/main/java/vip/mate/agent/context/LoopMessageBudgeter.java
index 356ac616..c5c1df00 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/context/LoopMessageBudgeter.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/LoopMessageBudgeter.java
@@ -145,11 +145,13 @@ public class LoopMessageBudgeter {
int hardTailCount = originalCount - hardTailStart;
if (hardTailCount < cfg.minTailMessages()) {
int floorTailStart = Math.max(headEnd, originalCount - cfg.minTailMessages());
- // Honor the soft ceiling: if even the floor count would consume
- // more than tailSoftCeilingTokens, accept it (the floor wins,
- // since the alternative is losing recent reasoning entirely).
- tailStart = floorTailStart;
- minTailFloorApplied = true;
+ // The count floor is a heuristic, never a permission to retain
+ // ordinary messages beyond the actual remaining token allowance.
+ if (TokenEstimator.estimateTokens(messages.subList(floorTailStart, originalCount))
+ <= cfg.tailSoftCeilingTokens()) {
+ tailStart = floorTailStart;
+ minTailFloorApplied = true;
+ }
} else {
// Apply the soft ceiling: if the hard cut undershoots the soft
// ceiling (i.e. there's slack), keep going. We already cut to
@@ -163,7 +165,13 @@ public class LoopMessageBudgeter {
boolean anchorEnforced = false;
int anchorIdx = findLatestUserMessageIdx(messages, headEnd);
if (anchorIdx >= 0 && anchorIdx < tailStart) {
- tailStart = anchorIdx;
+ // Preserve a nearby anchor in place, but do not drag an entire
+ // long tool history back into a token-bounded tail. The build
+ // step below stitches a distant anchor ahead of the selected tail.
+ if (TokenEstimator.estimateTokens(messages.subList(anchorIdx, originalCount))
+ <= cfg.tailSoftCeilingTokens()) {
+ tailStart = anchorIdx;
+ }
anchorEnforced = true;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/TokenEstimator.java b/mateclaw-server/src/main/java/vip/mate/agent/context/TokenEstimator.java
index cb974fae..37f691ba 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/context/TokenEstimator.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/TokenEstimator.java
@@ -1,6 +1,8 @@
package vip.mate.agent.context;
import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
@@ -66,7 +68,20 @@ public final class TokenEstimator {
if (message == null) {
return 0;
}
- return estimateTokens(message.getText()) + PER_MESSAGE_OVERHEAD;
+ int total = estimateTokens(message.getText()) + PER_MESSAGE_OVERHEAD;
+ // Tool payloads are structured fields, absent from Message.getText().
+ if (message instanceof ToolResponseMessage response) {
+ for (var result : response.getResponses()) {
+ total += PER_MESSAGE_OVERHEAD + estimateTokens(result.id())
+ + estimateTokens(result.name()) + estimateTokens(result.responseData());
+ }
+ } else if (message instanceof AssistantMessage assistant && assistant.getToolCalls() != null) {
+ for (var call : assistant.getToolCalls()) {
+ total += PER_MESSAGE_OVERHEAD + estimateTokens(call.id())
+ + estimateTokens(call.type()) + estimateTokens(call.name()) + estimateTokens(call.arguments());
+ }
+ }
+ return total;
}
/**
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
index 777650fb..b8dad868 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
@@ -19,6 +19,10 @@ import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.context.ConversationWindowManager;
+import vip.mate.agent.context.BudgetedCompactionModel;
+import vip.mate.agent.context.PrefixBudgetPlan;
+import vip.mate.llm.chatmodel.ChatModelDefaults;
+import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.workspace.conversation.ConversationService;
@@ -57,6 +61,11 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
* builder that already threads a dozen collaborators, and this is a single
* boolean with a safe default.
*/
+ private PrefixBudgetPlan prefixBudgetPlan;
+ private static final String PRE_RUN_ABORTED = "pre_run_context_aborted";
+
+ public void setPrefixBudgetPlan(PrefixBudgetPlan plan) { this.prefixBudgetPlan=plan; }
+
private boolean persistEveryIterationReasoning = true;
public void setPersistEveryIterationReasoning(boolean persistEveryIterationReasoning) {
@@ -96,7 +105,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// executions) inherit the prior run's accumulated messages and
// counters. Mirrors the streaming paths, which already do this.
- Optional result = compiledGraph.invoke(inputs);
+ Optional result = invokePrepared(inputs);
return result
.flatMap(s -> s.value(FINAL_ANSWER))
@@ -122,7 +131,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
- return compiledGraph.stream(inputs)
+ return streamPrepared(inputs)
.filter(this::hasFinalAnswer)
.map(this::extractFinalAnswer)
.filter(content -> content != null && !content.isEmpty())
@@ -157,7 +166,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
}
// Fresh thread per invocation — see chat() for rationale.
- Optional result = compiledGraph.invoke(inputs);
+ Optional result = invokePrepared(inputs);
return result
.flatMap(s -> s.value(FINAL_ANSWER))
@@ -212,7 +221,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicInteger lastSoftCap = new AtomicInteger(0);
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
- return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs)
+ return BaseAgent.routingStartupDelta(inputs).concatWith(streamPrepared(inputs)
.flatMapIterable(output -> {
List deltas = new ArrayList<>();
List allEvents = GraphEventPublisher.extractEvents(output);
@@ -428,7 +437,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicInteger lastSoftCap = new AtomicInteger(0);
AtomicBoolean sawLegitimateExit = new AtomicBoolean(false);
- return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs)
+ return BaseAgent.routingStartupDelta(inputs).concatWith(streamPrepared(inputs)
.flatMapIterable(output -> {
List deltas = new ArrayList<>();
// 1. 提取所有累积的事件,只发送新增部分
@@ -583,33 +592,67 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
}
}
+ private Optional invokePrepared(Map inputs) {
+ return Boolean.TRUE.equals(inputs.get(PRE_RUN_ABORTED))
+ ? Optional.of(new RunState(inputs)) : compiledGraph.invoke(inputs);
+ }
+
+ private Flux streamPrepared(Map inputs) {
+ return Boolean.TRUE.equals(inputs.get(PRE_RUN_ABORTED))
+ ? Flux.just(new RunStep("finalAnswer",new RunState(inputs))) : compiledGraph.stream(inputs);
+ }
+
private Map buildInitialState(String userMessage, String conversationId) {
var incoming = vip.mate.agent.context.ChatOriginHolder.get().withConversationId(conversationId);
var memberOrigin = MemberFileIsolation.scope(incoming.withWorkspace(incoming.workspaceId(), this.workspaceBasePath));
String workspaceBasePath = memberOrigin.workspaceBasePath();
- // 加载会话历史
- List historyMessages = buildConversationHistory(conversationId, userMessage);
+ String thinkingLevel = vip.mate.llm.chatmodel.ThinkingLevelHolder.get();
+ boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
+ int effectiveMaxIterations = maxIterations <= 0 ? 0
+ : (int)Math.min(Integer.MAX_VALUE,(long)maxIterations + (thinkingOn ? 5L : 0L));
+ long setupCalls = effectiveMaxIterations <= 0 ? Long.MAX_VALUE
+ : (long)effectiveMaxIterations * 5L - 1L;
+ int effectiveWindow = prefixBudgetPlan != null
+ ? (prefixBudgetPlan.enabled() ? prefixBudgetPlan.effectiveMaxTokens() : 0)
+ : (maxInputTokens != null && maxInputTokens > 0 ? maxInputTokens : 0);
+ var defaults=chatModel == null ? null : ChatModelDefaults.optionsFor(chatModel);
+ int fallbackOutput = BudgetedCompactionModel.outputReserveTokens(defaults,1024);
+ BudgetedCompactionModel setupModel = new BudgetedCompactionModel(chatModel,setupCalls,effectiveWindow,fallbackOutput);
+ boolean setupCancelled = false;
+ // Own a detached history projection; compaction must not rewrite canonical messages.
+ List historyMessages = new RunState(Map.of(MESSAGES,
+ buildConversationHistory(conversationId, userMessage))).value(MESSAGES,List.of());
// 上下文窗口管理:裁剪超出模型 context window 的历史(含当前消息预算)
if (conversationWindowManager != null) {
Long parsedAgentId = null;
try { parsedAgentId = Long.valueOf(agentId); } catch (Exception ignored) {}
- historyMessages = conversationWindowManager.fitToWindow(
+ try {
+ historyMessages = conversationWindowManager.fitToWindow(
historyMessages,
systemPrompt != null ? systemPrompt : "",
userMessage,
- maxInputTokens,
- chatModel,
+ prefixBudgetPlan != null && prefixBudgetPlan.enabled() ? Integer.valueOf(effectiveWindow) : maxInputTokens,
+ setupModel,
conversationId,
parsedAgentId,
toolSet != null ? toolSet.callbacks() : null,
workspaceBasePath);
+ } catch (java.util.concurrent.CancellationException stopped) {
+ setupCancelled = true;
+ } catch (BudgetedCompactionModel.CallBudgetExceeded | BudgetedCompactionModel.SummaryPromptOverflow rejected) {
+ // The wrapper retains the reason even when CWM swallowed it first.
+ }
}
+ setupCancelled |= setupModel.cancellation() != null;
+ boolean setupAborted = setupCancelled || setupModel.blockedPrompt() != null || setupModel.callBudgetBlocked();
List messages = new ArrayList<>(historyMessages);
// 构建当前用户消息:支持 multimodal(如果有图片附件,直接注入 Media)
// 同步获取 routing decision,写入 state 供后续节点 / accumulator 读取。
- BaseAgent.CurrentTurnUserMessage currentTurn = buildCurrentUserMessageWithRouting(conversationId, userMessage);
+ BaseAgent.CurrentTurnUserMessage currentTurn = setupAborted
+ ? new BaseAgent.CurrentTurnUserMessage(new UserMessage(userMessage),null)
+ : buildCurrentUserMessageWithRouting(conversationId, userMessage);
messages.add(currentTurn.userMessage());
Map inputs = new HashMap<>();
@@ -623,16 +666,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// 迭代控制:深度思考模式允许更多迭代(思考需要更多轮工具调用)
// maxIterations<=0 表示软上限解除(由 LLM 自己决定何时收尾),加分要短路,
// 否则 thinking-on 会把"无限"误算成 5(变成"5 步就停")。
- String thinkingLevel = vip.mate.llm.chatmodel.ThinkingLevelHolder.get();
if (thinkingLevel != null) inputs.put(REQUESTED_THINKING_LEVEL, thinkingLevel);
inputs.put(SUBAGENT_RUN_CONTEXT, DelegationContext.current());
- boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
- int effectiveMaxIterations = (maxIterations <= 0)
- ? 0
- : (thinkingOn ? maxIterations + 5 : maxIterations);
inputs.put(MAX_ITERATIONS, effectiveMaxIterations);
inputs.put(CURRENT_ITERATION, 0);
// 初始化新字段
+ inputs.put(LLM_CALL_COUNT,setupModel.calls());
inputs.put(TOOL_CALL_COUNT, 0);
inputs.put(ERROR_COUNT, 0);
inputs.put(SHOULD_SUMMARIZE, false);
@@ -650,6 +689,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(CACHE_READ_TOKENS, 0);
inputs.put(CACHE_WRITE_TOKENS, 0);
inputs.put(REASONING_TOKENS, 0);
+ inputs.putAll(setupModel.usage());
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
@@ -707,6 +747,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, false);
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
+ if (setupAborted) {
+ String reason=setupCancelled ? FinishReason.STOPPED.getValue() : FinishReason.INCOMPLETE.getValue();
+ inputs.put(PRE_RUN_ABORTED,true);
+ inputs.put(NEEDS_TOOL_CALL,false);
+ inputs.put(CONTINUE_REASONING,false);
+ inputs.put(FINISH_REASON,reason);
+ inputs.put(FINAL_ANSWER,setupCancelled ? "" : "(上下文准备无法在模型窗口或调用预算内安全完成,任务尚未完成。)");
+ inputs.put(PENDING_EVENTS,List.of(GraphEventPublisher.finishReason(reason)));
+ }
return inputs;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ReasoningDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ReasoningDispatcher.java
index 13f5d446..6db6ccfa 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ReasoningDispatcher.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ReasoningDispatcher.java
@@ -13,6 +13,7 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
* 迭代超限 → limitExceededNode
* 可直接回答(含 fatal error 自带的错误文案) → finalAnswerNode
* LLM 调用次数超限 → limitExceededNode(仅拦截继续循环的路径)
+ * 完成门要求继续推理 → reasoningNode
* 需要工具调用 → actionNode
* 需要总结压缩 → summarizingNode
* 兜底 → finalAnswerNode
@@ -41,46 +42,47 @@ public class ReasoningDispatcher implements EdgeAction {
return LIMIT_EXCEEDED_NODE;
}
- if (accessor.continueReasoning()) {
- log.info("[ReasoningDispatcher] Completion gate requested another reasoning pass");
- return REASONING_NODE;
- }
-
// 2. 可直接回答 → finalAnswerNode
// 覆盖以下场景:
// - LLM 正常产出最终回答 (needsToolCall=false, finalAnswer 非空)
// - fatal error (ReasoningNode 设置了 finalAnswer=错误文案 + finishReason=ERROR_FALLBACK)
// - 用户停止无内容 (finalAnswer="" + finishReason=STOPPED)
// 不受 llm_call_count 限制 — 已有结果不应丢弃。
- if (!accessor.needsToolCall() && !accessor.shouldSummarize()) {
+ if (!accessor.continueReasoning() && !accessor.needsToolCall() && !accessor.shouldSummarize()) {
log.debug("[ReasoningDispatcher] Routing to finalAnswerNode (direct answer)");
return FINAL_ANSWER_NODE;
}
- // 3. LLM 调用次数超限 — 仅拦截继续循环(工具调用/总结)的路径(maxIterations=0 不限制)
+ // 3. LLM 调用次数超限 — 拦截所有继续循环的路径(maxIterations=0 不限制)
int llmCallCount = accessor.llmCallCount();
int maxIter = accessor.maxIterations();
- int llmCallLimit = maxIter > 0 ? maxIter * LLM_CALL_MULTIPLIER : Integer.MAX_VALUE;
- if (llmCallCount >= llmCallLimit) {
+ long llmCallLimit = (long) maxIter * LLM_CALL_MULTIPLIER;
+ if (maxIter > 0 && llmCallCount >= llmCallLimit) {
log.warn("[ReasoningDispatcher] LLM call count limit reached ({}/{}), " +
"routing to limitExceededNode instead of continuing loop",
llmCallCount, llmCallLimit);
return LIMIT_EXCEEDED_NODE;
}
- // 4. 工具调用
+ // 4. 完成门要求继续推理
+ if (accessor.continueReasoning()) {
+ log.info("[ReasoningDispatcher] Completion gate requested another reasoning pass");
+ return REASONING_NODE;
+ }
+
+ // 5. 工具调用
if (accessor.needsToolCall()) {
log.debug("[ReasoningDispatcher] Routing to actionNode (tool call needed)");
return ACTION_NODE;
}
- // 5. 需要总结
+ // 6. 需要总结
if (accessor.shouldSummarize()) {
log.info("[ReasoningDispatcher] Routing to summarizingNode (observation context too large)");
return SUMMARIZING_NODE;
}
- // 6. 兜底
+ // 7. 兜底
log.debug("[ReasoningDispatcher] Routing to finalAnswerNode (fallback)");
return FINAL_ANSWER_NODE;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultProperties.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultProperties.java
index f016df66..696f152c 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultProperties.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultProperties.java
@@ -102,7 +102,7 @@ public class ToolResultProperties {
* reads are already self-paginated to a bounded size.
*/
private List excludedTools = List.of(
- "read_file", "read_workspace_memory_file", "readSkillFile", "load_skill");
+ "get_progress", "read_file", "read_workspace_memory_file", "readSkillFile", "load_skill");
/**
* Days to retain spill files before the scheduled cleanup deletes them.
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java
index 850ccf04..3b3d6f38 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java
@@ -77,7 +77,7 @@ public class ActionNode implements NodeAction {
*
*/
private static final Set AUTO_RECORD_SKIP = Set.of(
- LOAD_SKILL_TOOL, ENABLE_TOOL, TOOL_SEARCH, TOOL_DESCRIBE, PROGRESS_UPDATE_TOOL,
+ LOAD_SKILL_TOOL, ENABLE_TOOL, TOOL_SEARCH, TOOL_DESCRIBE, PROGRESS_UPDATE_TOOL, "get_progress",
"listAvailableSkills", "readSkillFile", "runSkillScript",
// read-only / status-query tools
"read_file", "web_search",
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java
index 7edff5c1..b8e9bc83 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java
@@ -325,7 +325,7 @@ public class GoalEvaluationNode implements NodeAction {
boolean hardCapReached = reactIterationCapReached && hardContinuationCount >= hardCap;
// Caps are deterministic guards and must be resolved before consulting a provider.
GoalDecisionAdapter.Selection selection = decisionAdapter == null || !planningSucceeded ? null
- : decisionAdapter.selectGraph(refreshed, followup, LocalDateTime.now(), perRunCapReached || hardCapReached);
+ : decisionAdapter.selectGraph(refreshed, followup, LocalDateTime.now(), perRunCapReached || hardCapReached, result);
boolean followupSuppressed = selection != null && selection.ticket().mode() == DecisionMode.ACTIVE
&& followup.isPresent() && !perRunCapReached && !hardCapReached
&& (selection.decision().action() == Action.DEFER || selection.decision().action() == Action.RETRY);
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/LimitExceededNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/LimitExceededNode.java
index d20f22c5..75278598 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/LimitExceededNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/LimitExceededNode.java
@@ -8,6 +8,10 @@ import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.chat.prompt.ChatOptions;
+import org.springframework.ai.model.tool.ToolCallingChatOptions;
+import vip.mate.llm.chatmodel.ChatModelDefaults;
+import vip.mate.agent.context.TokenEstimator;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.observation.ObservationProcessor;
import vip.mate.agent.graph.state.FinishReason;
@@ -56,6 +60,12 @@ public class LimitExceededNode implements NodeAction {
*/
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
+ private vip.mate.agent.context.PrefixBudgetPlan prefixBudgetPlan;
+
+ public void setPrefixBudgetPlan(vip.mate.agent.context.PrefixBudgetPlan plan) {
+ this.prefixBudgetPlan = plan;
+ }
+
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
NodeStreamingChatHelper streamingHelper) {
this(chatModel, observationProcessor, streamingHelper, null, null);
@@ -124,7 +134,9 @@ public class LimitExceededNode implements NodeAction {
String ledgerSnapshot = null;
if (progressLedgerService != null && conversationId != null && !conversationId.isBlank()) {
try {
- ledgerSnapshot = progressLedgerService.load(conversationId).renderSnapshot();
+ var ledger = progressLedgerService.load(conversationId);
+ ledgerSnapshot = prefixBudgetPlan != null && prefixBudgetPlan.enabled()
+ ? ledger.renderSnapshot(prefixBudgetPlan.ledgerTokens()) : ledger.renderSnapshot();
} catch (Exception e) {
log.warn("[LimitExceededNode] Failed to load progress ledger for {}: {}",
conversationId, e.getMessage());
@@ -142,11 +154,38 @@ public class LimitExceededNode implements NodeAction {
List promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(systemPrompt));
+ String executionFacts = accessor.actionExecutionLedger().renderAnswerFacts();
+ if (!executionFacts.isBlank()) promptMessages.add(new SystemMessage(executionFacts));
promptMessages.add(new UserMessage(userPrompt));
- // 流式调用 LLM,实时推送 content/thinking
+ ChatOptions options = null;
+ if (prefixBudgetPlan != null && prefixBudgetPlan.enabled()) {
+ ChatOptions defaults = ChatModelDefaults.optionsFor(chatModel);
+ int outputReserve = ReasoningNode.outputReserveTokens(defaults,1024);
+ boolean declaredOutput = defaults != null && (defaults.getMaxTokens() != null
+ || defaults instanceof org.springframework.ai.openai.OpenAiChatOptions openAi
+ && openAi.getMaxCompletionTokens() != null);
+ options = declaredOutput ? defaults.mutate().build()
+ : defaults != null ? defaults.mutate().maxTokens(outputReserve).build()
+ : ChatOptions.builder().maxTokens(outputReserve).build();
+ if (options instanceof ToolCallingChatOptions toolOptions) {
+ options = toolOptions.mutate().toolCallbacks(List.of()).build();
+ }
+ int total = TokenEstimator.estimateTokens(promptMessages) + outputReserve;
+ if (total > prefixBudgetPlan.effectiveMaxTokens()) {
+ log.error("[LimitExceededNode] Wrap-up prompt estimate {} + output {} exceeds window {}",
+ TokenEstimator.estimateTokens(promptMessages),outputReserve,prefixBudgetPlan.effectiveMaxTokens());
+ return MateClawStateAccessor.output()
+ .finalAnswerDraft("(必需任务记录与输出预留超过模型上下文窗口,无法安全生成总结。任务尚未完成。)")
+ .llmCallCount(accessor.llmCallCount()).limitExceeded(true)
+ .contentStreamed(false).thinkingStreamed(accessor.thinkingStreamed())
+ .finishReason(FinishReason.INCOMPLETE).build();
+ }
+ }
+ // The graceful wrap retains its separate one-call allowance and advertises no tools.
+ Prompt prompt = options == null ? new Prompt(promptMessages) : new Prompt(promptMessages,options);
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
- chatModel, new Prompt(promptMessages), conversationId, "limit_exceeded");
+ chatModel, prompt, conversationId, "limit_exceeded");
String finalDraft = result.text();
@@ -158,6 +197,7 @@ public class LimitExceededNode implements NodeAction {
: "Sorry, the maximum reasoning steps were reached.";
return MateClawStateAccessor.output()
.finalAnswerDraft(finalDraft != null ? finalDraft : fallbackMsg)
+ .llmCallCount(accessor.llmCallCount()+1)
.currentThinking(result.thinking())
.limitExceeded(true)
.contentStreamed(true)
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 644827b8..ee933cdb 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
@@ -30,6 +30,9 @@ import vip.mate.agent.context.PrefixBudgetPlan;
import vip.mate.agent.context.LoopMessageBudgeter;
import vip.mate.agent.context.RuntimeContextInjector;
import vip.mate.agent.context.TokenEstimator;
+import vip.mate.agent.context.BudgetedCompactionModel;
+import vip.mate.agent.context.BudgetedCompactionModel.CallBudgetExceeded;
+import vip.mate.agent.context.BudgetedCompactionModel.SummaryPromptOverflow;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.MateClawStateKeys;
@@ -101,18 +104,6 @@ public class ReasoningNode implements NodeAction {
*/
private static final int DEFAULT_LOOP_CONTEXT_WINDOW_TOKENS = 128_000;
- /**
- * Conservative buffer added to the per-loop budget's reservedPrefixTokens
- * to cover non-history prompt segments that are appended after
- * the budget runs: the runtime-rendered skill catalog, runtime-context
- * snapshot, wiki injection, progress ledger snapshot, and assorted
- * marker SystemMessages. Underestimating here only delays the trigger
- * slightly; loop invariants (anchor preservation, tool-pair integrity)
- * are unaffected. Sized for a typical agent with 20–30 skills and
- * moderate wiki content.
- */
- private static final int LOOP_PREFIX_AUXILIARY_RESERVE_TOKENS = 4_000;
-
/**
* DashScope's native chat API caps {@code max_tokens} at 8192 and returns a
* 400 {@code InvalidParameter} ("Range of max_tokens should be [1, 8192]")
@@ -372,11 +363,12 @@ public class ReasoningNode implements NodeAction {
+ " 每批最多 16 个 `progress_update`;超过 16 个时分批注册,避免超过执行器上限。\n"
+ " 例:要调研 10 个模型,第一轮就发 10 个 `progress_update(stepKey=\"model_xxx\", status=\"pending\")`。\n"
+ "2. **每开始一个子目标**前发 `progress_update(同 stepKey, status=\"in_progress\")`。\n"
- + "3. **每完成一个子目标**后立即发 `progress_update(同 stepKey, status=\"done\")`。\n"
+ + "3. **收到并验证子目标的成功结果后**,在下一次行动中发 `progress_update(同 stepKey, status=\"done\")`。\n"
+ + " 不要在启动该操作的批次提前标记 done;可以把前一步的 done 更新与下一独立步骤的工具调用放在同一批次。\n"
+ "4. **无法继续**时发 `progress_update(同 stepKey, status=\"blocked\", note=\"具体原因\")`。\n\n"
+ "**为什么必须**:\n"
+ "- 系统在你**每一次推理前**注入一份 \"## 当前任务进度\" 快照。\n"
- + " 这是你**唯一可信**的\"已完成清单\"——比你记忆里的步骤更权威,因为上下文窗口\n"
+ + " 这是持久保存的进度声明,done 本身不是执行成功或验收通过的证据;必须结合真实工具结果验证。上下文窗口\n"
+ " 会被裁剪,老的工具调用记录会消失,但 ledger 不会。\n"
+ "- 不维护 ledger 的后果(实测):\n"
+ " · 上下文裁剪后忘记自己做过的步骤,重复执行已完成项 → 浪费迭代预算\n"
@@ -772,6 +764,10 @@ public class ReasoningNode implements NodeAction {
}
}
+ if (!hasLlmCallBudget(accessor.maxIterations(), accessor.llmCallCount(), 1)) {
+ return retryBudgetStop(state, accessor.llmCallCount(), null);
+ }
+
// ======= 构建 Prompt =======
String systemPrompt = accessor.systemPrompt();
// Tool-use enforcement is always appended: without it some models tend to
@@ -786,51 +782,6 @@ public class ReasoningNode implements NodeAction {
systemPrompt = buildGroundedSystemPrompt(systemPrompt, hasWikiTool());
List messages = accessor.messages();
- // Per-loop budget: bound the working message list a single Reasoning
- // iteration hands to the LLM. The previous fixed head=4 + tail=36 cut
- // could lose the latest UserMessage once the ReAct loop accumulated
- // tool calls/observations past ~70 messages — the user's question
- // fell into the dropped middle, the LLM lost it, and the agent
- // answered off-topic. LoopMessageBudgeter anchors the latest
- // UserMessage as undroppable, sizes the tail by token budget instead
- // of message count, and keeps the same bidirectional tool-pair
- // integrity guard the old block already had. The L2 trim here is
- // distinct from ConversationWindowManager (L1): L1 runs once per
- // user turn and produces an LLM summary for multi-turn history; L2
- // runs per reasoning iteration on what L1 already produced plus
- // intra-turn tool-call growth.
- //
- // Reserved prefix tokens cover the non-history portion of the
- // prompt the LLM will receive: system prompt (with tool-use
- // enforcement already appended), tool schemas, output reserve,
- // and a buffer for skill catalog + runtime context + wiki
- // injections that are added downstream. Underestimating here only
- // delays the trigger slightly — invariants (anchor, pair integrity)
- // still hold once budget fires.
- int systemTokens = TokenEstimator.estimateTokens(systemPrompt);
- int toolsTokens = TokenEstimator.estimateToolsTokens(toolCallbacks);
- int loopReservedPrefixTokens = systemTokens + toolsTokens
- + maxOutputTokens + LOOP_PREFIX_AUXILIARY_RESERVE_TOKENS;
- LoopBudgetConfig loopCfg = LoopBudgetConfig.forContext(loopContextWindowTokens())
- .withReservedPrefixTokens(loopReservedPrefixTokens);
- LoopMessageBudgeter.Result budgeted = LOOP_BUDGETER.budget(messages, loopCfg);
- // 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();
- log.warn("[ReasoningNode] Loop budget trim: {} -> {} msgs (history {} -> {} tokens, "
- + "prefix~{}), head={}, tail={}, droppedMiddle={}, orphans={}, "
- + "anchorEnforced={}, anchorStitched={}, targetMaxTripped={}, "
- + "capExceededForPairIntegrity={}, minTailFloorApplied={}, conv={}",
- t.originalCount(), t.finalCount(), t.originalTokens(), t.finalTokens(),
- t.reservedPrefixTokens(),
- t.headKept(), t.tailKept(), t.droppedMiddle(), t.orphansRemoved(),
- t.anchorEnforced(), t.anchorStitched(), t.targetMaxTripped(),
- t.capExceededForPairIntegrity(), t.minTailFloorApplied(), conversationId);
- }
- messages = budgeted.messages();
-
String workspaceBasePath = state.value(vip.mate.agent.graph.state.MateClawStateKeys.WORKSPACE_BASE_PATH, "");
String agentIdStr = state.value(MateClawStateKeys.AGENT_ID, "");
String userMsg = state.value(MateClawStateKeys.USER_MESSAGE, "");
@@ -879,7 +830,8 @@ public class ReasoningNode implements NodeAction {
try {
vip.mate.agent.progress.ProgressLedger ledger =
progressLedgerService.load(conversationId);
- String snapshot = ledger.renderSnapshot();
+ String snapshot = prefixBudgetPlan != null && prefixBudgetPlan.enabled()
+ ? ledger.renderSnapshot(prefixBudgetPlan.ledgerTokens()) : ledger.renderSnapshot();
if (snapshot != null) {
nonHistoryPrefix.add(new SystemMessage(snapshot));
}
@@ -960,8 +912,6 @@ public class ReasoningNode implements NodeAction {
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
messages, conversationId, workspaceBasePath);
}
- List promptMessages = new ArrayList<>(nonHistoryPrefix);
- promptMessages.addAll(messages);
// 请求级思考深度覆盖(ThinkingLevelHolder 由 AgentService 设置)
String effectiveReasoning = resolveEffectiveReasoningEffort();
@@ -979,6 +929,39 @@ public class ReasoningNode implements NodeAction {
activeCallbacks = filterLongFormArtifactTools(accessor.userMessage(), activeCallbacks);
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);
+ // 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();
+ log.warn("[ReasoningNode] Loop budget trim: {} -> {} msgs (history {} -> {} tokens, "
+ + "prefix~{}), head={}, tail={}, droppedMiddle={}, orphans={}, "
+ + "anchorEnforced={}, anchorStitched={}, targetMaxTripped={}, "
+ + "capExceededForPairIntegrity={}, minTailFloorApplied={}, conv={}",
+ t.originalCount(), t.finalCount(), t.originalTokens(), t.finalTokens(),
+ t.reservedPrefixTokens(),
+ t.headKept(), t.tailKept(), t.droppedMiddle(), t.orphansRemoved(),
+ 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);
@@ -995,19 +978,23 @@ public class ReasoningNode implements NodeAction {
conversationId, loopContextWindowTokens(), prefixEstimateTokens,
toolSchemaEstimateTokens, TokenEstimator.estimateTokens(messages));
}
- // The prefix cannot be compacted (history compaction is the only lever),
- // so a prefix that alone exceeds the window makes the request doomed —
- // fail fast with the same PROMPT_TOO_LONG shape a provider rejection
- // would produce instead of sending it. Gated on budgeting being active
- // (an estimation false-positive must not block requests otherwise).
- boolean prefixOverflow = prefixBudgetPlan != null && prefixBudgetPlan.enabled()
- && prefixEstimateTokens + toolSchemaEstimateTokens > prefixBudgetPlan.effectiveMaxTokens();
+ // Check the complete assembled request, including indivisible latest
+ // 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();
+ int assembledPromptEstimateTokens = TokenEstimator.estimateTokens(promptMessages)
+ + toolSchemaEstimateTokens + outputReserveTokens;
+ boolean promptOverflow = prefixBudgetPlan != null && prefixBudgetPlan.enabled()
+ && assembledPromptEstimateTokens > prefixBudgetPlan.effectiveMaxTokens();
+ boolean localPromptRejected = promptOverflow;
// ======= LLM 调用区域 =======
// nextLlmCallCount 在首次 streamCall 之前计算。
// 所有退出路径(正常、stopped、fatal error、CancellationException)都必须写回此值。
// PTL compact retry 会再 +1。
- int nextLlmCallCount = accessor.llmCallCount() + 1;
+ int nextLlmCallCount = accessor.llmCallCount();
log.debug("[ReasoningNode] Calling LLM with {} messages, {} tool definitions, iteration {}/{}, llmCallCount={}",
promptMessages.size(), activeCallbacks.size(),
accessor.iterationCount(), accessor.maxIterations(), nextLlmCallCount);
@@ -1030,26 +1017,35 @@ public class ReasoningNode implements NodeAction {
"llmCallCount", nextLlmCallCount
));
- NodeStreamingChatHelper.StreamResult result;
+ NodeStreamingChatHelper.StreamResult result = null;
try {
- if (prefixOverflow) {
- String overflowMessage = "Prompt 前缀估算 " + (prefixEstimateTokens + toolSchemaEstimateTokens)
+ if (promptOverflow) {
+ String overflowMessage = (mandatoryPrefixOverflow
+ ? "受保护前缀/原始目标无法容纳" : "不可拆分的当前历史/工具对无法容纳")
+ + ": Prompt 估算 " + assembledPromptEstimateTokens
+ " tokens(注入块 " + prefixEstimateTokens + " + 工具 schema " + toolSchemaEstimateTokens
- + ")已超过模型上下文窗口 " + prefixBudgetPlan.effectiveMaxTokens()
- + " tokens,历史压缩无法解决——请精简 Agent 身份 prompt、减少绑定工具/技能,"
- + "或换用更大窗口的模型";
+ + " + 历史 " + TokenEstimator.estimateTokens(messages)
+ + " + 输出预留 " + outputReserveTokens + ")超过模型上下文窗口 "
+ + prefixBudgetPlan.effectiveMaxTokens()
+ + " tokens;已保留原始目标与完整工具对,无法安全继续压缩,请使用更大窗口或缩短必需上下文。";
log.error("[ReasoningNode] {}", overflowMessage);
result = new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false,
0, 0, false, overflowMessage,
NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0);
} else {
+ nextLlmCallCount++;
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
}
// PTL 处理:结构化压缩后重试。复用 nonHistoryPrefix 保证重试
// Prompt 仍带 wiki / runtime context;早期的 tail-only 路径会把
// wiki 段一起丢掉,重试后的 prompt 比原始更短少一层信息。
- if (result.isPromptTooLong() && conversationWindowManager != null) {
+ if (!promptOverflow && result.isPromptTooLong() && conversationWindowManager != null) {
+ // Structured compaction can itself invoke the model. Keep one
+ // slot for the actual retry and do not start with just one left.
+ if (!hasLlmCallBudget(accessor.maxIterations(), nextLlmCallCount, 2)) {
+ return retryBudgetStop(state, nextLlmCallCount, result);
+ }
log.warn("[ReasoningNode] Prompt too long, attempting STRUCTURED compaction and retry");
// MateClawStateAccessor.agentId() returns String per state
@@ -1064,23 +1060,68 @@ public class ReasoningNode implements NodeAction {
}
}
- List compactedMessages = conversationWindowManager.compactForRetry(
- messages, chatModel, conversationId, agentIdLong);
+ BudgetedCompactionModel compactionModel = new BudgetedCompactionModel(chatModel,
+ remainingLlmCalls(accessor.maxIterations(), nextLlmCallCount) - 1,
+ prefixBudgetPlan != null && prefixBudgetPlan.enabled() ? loopContextWindowTokens() : 0,
+ outputReserveTokens);
+ List compactedMessages;
+ try {
+ compactedMessages = conversationWindowManager.compactForRetry(
+ messages, compactionModel, conversationId, agentIdLong);
+ } catch (CallBudgetExceeded exhausted) {
+ return retryBudgetStop(compactionModel.withUsage(state), nextLlmCallCount + compactionModel.calls(), result);
+ } catch (SummaryPromptOverflow oversized) {
+ compactedMessages = null;
+ } finally {
+ nextLlmCallCount += compactionModel.calls();
+ state = compactionModel.withUsage(state);
+ }
+ if (compactionModel.cancellation() != null) throw compactionModel.cancellation();
+ if (compactionModel.blockedPrompt() != null) {
+ localPromptRejected = true;
+ result = new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false,
+ 0, 0, false, compactionModel.blockedPrompt(),
+ 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);
- Prompt retryPrompt = new Prompt(retryPromptMessages, options);
- log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages",
- messages.size(), compactedMessages.size());
- nextLlmCallCount++;
- pushPhase(conversationId, "reasoning", Map.of(
- "iteration", accessor.iterationCount(),
- "llmCallCount", nextLlmCallCount,
- "compacted", true
- ));
- result = streamingHelper.streamCall(chatModel, retryPrompt, conversationId, "reasoning_compact_retry");
+ int retryEstimateTokens = TokenEstimator.estimateTokens(retryPromptMessages)
+ + toolSchemaEstimateTokens + outputReserveTokens;
+ if (prefixBudgetPlan != null && prefixBudgetPlan.enabled()
+ && retryEstimateTokens > prefixBudgetPlan.effectiveMaxTokens()) {
+ localPromptRejected = true;
+ String retryOverflowMessage = "压缩重试 Prompt 估算 " + retryEstimateTokens
+ + " tokens 仍超过上下文窗口 " + prefixBudgetPlan.effectiveMaxTokens()
+ + " tokens,保留完整前缀与必需历史后无法安全重试。";
+ log.error("[ReasoningNode] {}", retryOverflowMessage);
+ result = new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false,
+ 0, 0, false, retryOverflowMessage,
+ NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0);
+ } else {
+ Prompt retryPrompt = new Prompt(retryPromptMessages, options);
+ log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages",
+ messages.size(), compactedMessages.size());
+ nextLlmCallCount++;
+ pushPhase(conversationId, "reasoning", Map.of(
+ "iteration", accessor.iterationCount(),
+ "llmCallCount", nextLlmCallCount,
+ "compacted", true
+ ));
+ if (!hasLlmCallBudget(accessor.maxIterations(), nextLlmCallCount - 1, 1)) {
+ return retryBudgetStop(state, nextLlmCallCount - 1, result);
+ }
+ result = streamingHelper.streamCall(chatModel, retryPrompt, conversationId, "reasoning_compact_retry");
+ // Later nudges must use the history actually sent here.
+ messages = compactedMessages;
+ promptMessages = retryPromptMessages;
+ }
} else {
log.warn("[ReasoningNode] Compaction did not reduce messages, cannot retry");
}
@@ -1108,6 +1149,9 @@ public class ReasoningNode implements NodeAction {
for (ContinuationIntent intent = classifyContinuation(result);
emptyRetries < MAX_EMPTY_COMPLETION_RETRIES && intent != ContinuationIntent.FINAL;
intent = classifyContinuation(result)) {
+ if (!hasLlmCallBudget(accessor.maxIterations(), nextLlmCallCount, 1)) {
+ return retryBudgetStop(state, nextLlmCallCount, result);
+ }
emptyRetries++;
boolean afterTool = lastTurnIsToolResponse(promptMessages);
String nudge = afterTool
@@ -1118,12 +1162,36 @@ public class ReasoningNode implements NodeAction {
log.warn("[ReasoningNode] {} completion (afterTool={}); nudging to continue "
+ "(retry {}/{}), conv={}",
intent, afterTool, emptyRetries, MAX_EMPTY_COMPLETION_RETRIES, conversationId);
- List nudgedMessages = new ArrayList<>(promptMessages);
- nudgedMessages.add(new UserMessage(nudge));
+ // Runtime instructions belong in the prefix, not a new user
+ // 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);
+ int nudgeEstimate = TokenEstimator.estimateTokens(nudgedMessages)
+ + toolSchemaEstimateTokens + outputReserveTokens;
+ if (prefixBudgetPlan != null && prefixBudgetPlan.enabled()
+ && nudgeEstimate > prefixBudgetPlan.effectiveMaxTokens()) {
+ localPromptRejected = true;
+ result = new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false,
+ 0, 0, false, "空回复重试 Prompt 估算 " + nudgeEstimate
+ + " tokens 超过上下文窗口,无法保留完整前缀、原始目标与工具对。",
+ NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0);
+ break;
+ }
Prompt nudgePrompt = new Prompt(nudgedMessages, options);
nextLlmCallCount++;
result = streamingHelper.streamCall(
chatModel, nudgePrompt, conversationId, "reasoning_empty_retry");
+ messages = nudgeHistory;
+ promptMessages = nudgedMessages;
}
} catch (CancellationException ce) {
// "调用已发出但尚未产出内容时用户停止" — streamHelper 抛 CancellationException。
@@ -1136,6 +1204,8 @@ public class ReasoningNode implements NodeAction {
.needsToolCall(false)
.shouldSummarize(false)
.llmCallCount(nextLlmCallCount)
+ .mergeUsage(state, result != null ? result : new NodeStreamingChatHelper.StreamResult(
+ "", "", null, List.of(), false, 0, 0))
.finishReason(FinishReason.STOPPED)
.contentStreamed(true)
.thinkingStreamed(true)
@@ -1249,8 +1319,8 @@ public class ReasoningNode implements NodeAction {
.finalAnswer("[错误] " + result.errorMessage())
.llmCallCount(nextLlmCallCount)
.finishReason(FinishReason.ERROR_FALLBACK)
- .contentStreamed(true)
- .thinkingStreamed(true)
+ .contentStreamed(!localPromptRejected)
+ .thinkingStreamed(!localPromptRejected || accessor.thinkingStreamed())
.mergeUsage(state, result)
.build();
}
@@ -1472,6 +1542,57 @@ public class ReasoningNode implements NodeAction {
+ "。请继续检索/读取相关证据后再下结论。";
}
+ static boolean hasLlmCallBudget(int maxIterations, long usedCalls, long neededCalls) {
+ return maxIterations <= 0 || (long)maxIterations * 5L - usedCalls >= neededCalls;
+ }
+
+ private static long remainingLlmCalls(int maxIterations, long usedCalls) {
+ 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)
+ .finalAnswer("(本轮模型调用预算已用尽或不足以安全重试,任务尚未完成。)")
+ .llmCallCount(calls).finishReason(FinishReason.INCOMPLETE)
+ .contentStreamed(false).thinkingStreamed(new MateClawStateAccessor(state).thinkingStreamed()
+ || result != null && result.thinking() != null && !result.thinking().isBlank());
+ if (result != null) {
+ output.mergeUsage(state,result);
+ if (result.thinking() != null && !result.thinking().isBlank()) output.finalThinking(result.thinking());
+ }
+ return output.build();
+ }
+
+ static int outputReserveTokens(ChatOptions options, int fallback) {
+ return BudgetedCompactionModel.outputReserveTokens(options,fallback);
+ }
+
private AssistantMessage.ToolCall deserializeToolCall(String json) {
try {
@SuppressWarnings("unchecked")
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedger.java b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedger.java
index e9f4e2f3..93e9a485 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedger.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedger.java
@@ -159,12 +159,16 @@ public final class ProgressLedger {
* when the ledger is empty so the caller can skip injection
* entirely (no "(empty)" placeholder noise).
*/
+ public String renderSnapshot(int regularBudgetTokens) {
+ return ProgressLedgerProjection.render(this, regularBudgetTokens);
+ }
+
public String renderSnapshot() {
if (isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder(256);
- sb.append("## 当前任务进度(执行参考,权威记录)\n\n");
+ sb.append("## 当前任务进度(执行参考,模型状态并非成功证据)\n\n");
// Pinned constraints (highest priority — always visible)
if (!pinned.isEmpty()) {
@@ -175,9 +179,7 @@ public final class ProgressLedger {
sb.append("- ").append(label);
String note = e.getNote();
if (note != null && !note.isBlank()) {
- String trimmed = note.length() > NOTE_PREVIEW_CHARS
- ? note.substring(0, NOTE_PREVIEW_CHARS) + "…"
- : note;
+ String trimmed = note;
sb.append(" — ").append(trimmed);
}
sb.append('\n');
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerProjection.java b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerProjection.java
new file mode 100644
index 00000000..2bb7047b
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerProjection.java
@@ -0,0 +1,107 @@
+package vip.mate.agent.progress;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import vip.mate.agent.context.TokenEstimator;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/** A disposable display projection. Canonical entries and their identity never change. */
+public final class ProgressLedgerProjection {
+ public static final int DEFAULT_MAX_TOKENS = 2000;
+ private static final ObjectMapper JSON = new ObjectMapper();
+ private ProgressLedgerProjection() {}
+
+ public record IndexedEntry(int entryIndex, String origin, String identity, ProgressEntry entry) {}
+
+ public static List indexed(ProgressLedger ledger) {
+ List rows = new ArrayList<>();
+ ledger.asMap().forEach((key, entry) -> rows.add(new IndexedEntry(rows.size(),
+ key.startsWith(ProgressLedger.AUTO_RECORDED_PREFIX) ? "auto" : "model", key, entry)));
+ ledger.pinnedEntries().forEach((key, entry) -> rows.add(new IndexedEntry(rows.size(), "skill", key, entry)));
+ return rows;
+ }
+
+ public static Map counts(List rows) {
+ Map counts = new LinkedHashMap<>();
+ for (ProgressStatus status : ProgressStatus.values()) counts.put(status.name(), 0);
+ for (IndexedEntry row : rows) counts.compute(row.entry().getStatus().name(), (k,v) -> v+1);
+ return counts;
+ }
+
+ public static Map preview(IndexedEntry row) {
+ var entry = row.entry();
+ Map out = new LinkedHashMap<>();
+ out.put("entryIndex", row.entryIndex());
+ out.put("origin", row.origin());
+ out.put("status", entry.getStatus().name());
+ out.put("key", prefix(row.identity(), 80));
+ out.put("keyTruncated", length(row.identity()) > prefix(row.identity(),80).length());
+ out.put("label", prefix(entry.getLabel(), 100));
+ out.put("labelTruncated", length(entry.getLabel()) > prefix(entry.getLabel(),100).length());
+ out.put("note", prefix(entry.getNote(), 120));
+ out.put("noteTruncated", length(entry.getNote()) > prefix(entry.getNote(),120).length());
+ return out;
+ }
+
+ public static String render(ProgressLedger ledger, int regularBudgetTokens) {
+ if (ledger.isEmpty()) return null;
+ List all = indexed(ledger);
+ List regular = all.stream().filter(row -> !row.origin().equals("skill"))
+ .sorted(Comparator.comparingInt(row -> priority(row.entry().getStatus()))).toList();
+ int budget = Math.min(DEFAULT_MAX_TOKENS, Math.max(0, regularBudgetTokens));
+ List