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 696f152c..9cc8b72e 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
@@ -11,7 +11,7 @@ import java.util.Set;
*
Layer 1 — per-tool cap — is implemented inside each tool itself.
* Layer 2 — per-result spill — when a single tool result exceeds {@link #perResultThresholdChars}
* the full output is written to disk and only a {@link #previewHeadChars} preview
- * (plus a pointer line) is sent back to the LLM.
+ * and a bounded tail (plus a pointer line) are sent back to the LLM.
* Layer 3 — per-turn aggregate budget — after all tools in a turn complete, if
* the cumulative response size exceeds {@link #perTurnBudgetChars}, the largest
* non-spilled responses are spilled in turn until the aggregate fits.
@@ -71,6 +71,9 @@ public class ToolResultProperties {
/** Number of leading characters kept inline as a preview after spilling. */
private int previewHeadChars = 800;
+ /** Bounded trailing evidence, capped to the head allowance for small custom previews. */
+ private int previewTailChars = 400;
+
/**
* Retrieval-style tools are not spilled, but their inline content still must
* fit the model context. When aggregate turn budget is exceeded and only
@@ -144,6 +147,9 @@ public class ToolResultProperties {
this.previewHeadChars = previewHeadChars;
}
+ public int getPreviewTailChars() { return previewTailChars; }
+ public void setPreviewTailChars(int previewTailChars) { this.previewTailChars = previewTailChars; }
+
public int getExcludedToolInlineChars() { return excludedToolInlineChars; }
public void setExcludedToolInlineChars(int excludedToolInlineChars) {
this.excludedToolInlineChars = excludedToolInlineChars;
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java
index 062e9b6c..27d1f319 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java
@@ -293,18 +293,19 @@ public class ToolResultStorage {
}
private String buildPreview(String fullResult, String toolName, Path spillFile) {
- // Snap the preview to a complete JSON element so the model never sees a value
- // severed mid-token (which invites it to fabricate the omitted fields).
- String head = StructuredTruncator.headSlice(fullResult, props.getPreviewHeadChars());
+ int headBudget=Math.max(0,Math.min(props.getPreviewHeadChars(),fullResult.length()/3));
+ int tailBudget=Math.max(0,Math.min(props.getPreviewTailChars(),headBudget));
+ String marker="\n…[middle omitted — preview is INCOMPLETE]…\n";
+ String preview=StructuredTruncator.truncate(fullResult,headBudget,tailBudget,marker);
return SPILL_MARKER_PREFIX
+ " tool=" + toolName
+ " full_chars=" + fullResult.length()
+ " path=" + spillFile.toAbsolutePath()
- + "\n[Preview — first " + head.length() + " of " + fullResult.length()
- + " chars. The preview is INCOMPLETE: use read_file with the path above to "
- + "retrieve the full result. Do NOT infer or fabricate the omitted content.]\n"
- + head
- + "\n…[truncated]";
+ + "\n[INCOMPLETE head/tail preview, up to " + headBudget + "/" + tailBudget
+ + " chars. Use read_file with the path above for missing evidence. "
+ + "Do NOT infer or fabricate omitted content.]\n"
+ + preview;
+
}
/**
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/CodeExecutionBudget.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/CodeExecutionBudget.java
new file mode 100644
index 00000000..71a45d0b
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/CodeExecutionBudget.java
@@ -0,0 +1,128 @@
+package vip.mate.agent.graph.guard;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.ai.chat.messages.*;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.prompt.Prompt;
+import vip.mate.agent.context.TokenEstimator;
+import vip.mate.agent.graph.NodeStreamingChatHelper;
+import vip.mate.agent.graph.state.ActionExecutionLedger;
+import vip.mate.agent.graph.state.MateClawStateKeys;
+import vip.mate.agent.runtime.state.RunState;
+import vip.mate.agent.runtime.state.RuntimeExecutionScope;
+import vip.mate.channel.web.ChatStreamTracker;
+import java.util.*;
+import java.util.regex.Pattern;
+
+/** Pre-dispatch contract for explicit execute_code limits; no tool executes in this policy. */
+public final class CodeExecutionBudget {
+ public static final String REPLANS="code_execution_budget_replans";
+ public static final int MAX_REPLANS=2;
+ private static final ObjectMapper JSON=new ObjectMapper().enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
+ .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
+ private static final Pattern LIMIT=Pattern.compile(
+ "(?:^|[。!?\\n])\\s*(?:只|仅|最多|至多|必须依次|依次)\\s*(?:允许)?(?:调用|执行)\\s*(一|两|二|三|四|五|六|七|八|九|十|[1-9][0-9]?)次\\s*[`\"']?execute_code\\b",
+ Pattern.CASE_INSENSITIVE);
+ private static final Pattern ENGLISH_LIMIT=Pattern.compile("(?:^|[.!?\\n])\\s*at most\\s+([1-9][0-9]?)\\s+execute_code\\s+calls?",Pattern.CASE_INSENSITIVE);
+ public enum Decision { NOT_APPLICABLE, READY, REPLAN, BLOCKED, UNAVAILABLE, CANCELLED }
+ public record Review(Decision decision,int remaining,String reason,NodeStreamingChatHelper.StreamResult usage,int calls) {}
+ private CodeExecutionBudget() {}
+
+ /** Recognize only explicit positive limits; unsupported/contradictory wording is not guessed. */
+ public static OptionalInt limit(String objective) {
+ if(objective==null || objective.contains("不限次数") || objective.contains("取消调用次数限制"))return OptionalInt.empty();
+ String recognized=objective.replaceAll("(?s)```.*?```", "");
+ if(Pattern.compile("(?:若|如果|必要时|不够).{0,40}(?:继续调用|多次调用|可以继续|不限次数)").matcher(recognized).find())
+ return OptionalInt.empty();
+ Set values=new HashSet<>();var match=LIMIT.matcher(recognized);
+ while(match.find()) {
+ String n=match.group(1);String digits="一二三四五六七八九十";
+ int value="两".equals(n)?2:digits.contains(n)?digits.indexOf(n)+1:Integer.parseInt(n);
+ values.add(value);
+ }
+ match=ENGLISH_LIMIT.matcher(recognized);while(match.find())values.add(Integer.parseInt(match.group(1)));
+ return values.size()==1?OptionalInt.of(values.iterator().next()):OptionalInt.empty();
+ }
+
+ public static Review review(ChatModel model,NodeStreamingChatHelper helper,ChatStreamTracker tracker,
+ RunState state,String objective,List proposed,
+ ActionExecutionLedger ledger,String conversationId,int window,int usedModelCalls,int maxIterations) {
+ OptionalInt cap=limit(objective);
+ if(cap.isEmpty())return new Review(Decision.NOT_APPLICABLE,0,"",null,0);
+ List