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 5a46d4b0..8d7304e5 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
@@ -2,6 +2,9 @@ package vip.mate.agent.graph.executor;
import org.springframework.boot.context.properties.ConfigurationProperties;
+import java.util.List;
+import java.util.Set;
+
/**
* Configuration for the tool-result three-layer budget (RFC-008 Phase 3).
*
@@ -51,6 +54,18 @@ public class ToolResultProperties {
*/
private String storageBaseDir = "";
+ /**
+ * Tools whose results must NEVER be spilled. These are the tools the agent
+ * uses to retrieve spilled content — spilling their output would
+ * cause infinite recursion (read spill path → produces another spill →
+ * agent reads new spill → …) and starve {@code MAX_TOOL_CALLS_PER_STEP}.
+ *
+ *
Defaults to file-read tools that already cap their own output internally.
+ * Configurable so deployments can add more retrieval-style tools (e.g.,
+ * MCP-provided readers) without code changes.
+ */
+ private List excludedTools = List.of("read_file", "read_workspace_memory_file");
+
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
@@ -73,4 +88,14 @@ public class ToolResultProperties {
public void setStorageBaseDir(String storageBaseDir) {
this.storageBaseDir = storageBaseDir == null ? "" : storageBaseDir;
}
+
+ public List getExcludedTools() { return excludedTools; }
+ public void setExcludedTools(List excludedTools) {
+ this.excludedTools = excludedTools == null ? List.of() : excludedTools;
+ }
+
+ /** O(1) membership test for the exclusion list, used on every tool result. */
+ public Set excludedToolsSet() {
+ return Set.copyOf(excludedTools);
+ }
}
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 05eaac23..07a5c3bf 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
@@ -52,9 +52,29 @@ public class ToolResultStorage {
public static final String SPILL_MARKER_PREFIX = "[mate-tool-result-spill]";
private final ToolResultProperties props;
+ /** Cached at construction; refreshed lazily if the underlying list mutates (rare). */
+ private volatile java.util.Set excludedToolsSnapshot;
public ToolResultStorage(ToolResultProperties props) {
this.props = props;
+ this.excludedToolsSnapshot = props.excludedToolsSet();
+ }
+
+ /**
+ * Returns true when {@code toolName} is in the configured exclusion list.
+ * Excluded tools (typically retrieval tools like {@code read_file}) are
+ * never spilled — spilling their output would create a recursion where
+ * the agent reads a spill path and produces yet another spill.
+ */
+ private boolean isExcluded(String toolName) {
+ if (toolName == null) return false;
+ java.util.Set snap = excludedToolsSnapshot;
+ java.util.Set live = props.excludedToolsSet();
+ if (live != snap && !live.equals(snap)) {
+ this.excludedToolsSnapshot = live;
+ snap = live;
+ }
+ return snap.contains(toolName);
}
/**
@@ -73,6 +93,10 @@ public class ToolResultStorage {
if (!props.isEnabled() || result == null) {
return result;
}
+ if (isExcluded(toolName)) {
+ // Retrieval-style tool — never spill, would cause read-back recursion.
+ return result;
+ }
if (result.length() <= props.getPerResultThresholdChars()) {
return result;
}
@@ -115,20 +139,25 @@ public class ToolResultStorage {
List mutable = new ArrayList<>(responses);
while (aggregate > budget) {
- // Find the largest response that has not yet been spilled.
+ // Find the largest response that has not yet been spilled and is
+ // not produced by an excluded (retrieval-style) tool.
int targetIdx = -1;
int targetLen = -1;
for (int i = 0; i < mutable.size(); i++) {
- String body = mutable.get(i).responseData();
+ ToolResponseMessage.ToolResponse r = mutable.get(i);
+ String body = r.responseData();
if (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
+ if (isExcluded(r.name())) continue; // retrieval tools must not be spilled
if (body.length() > targetLen) {
targetLen = body.length();
targetIdx = i;
}
}
if (targetIdx < 0) {
- // Nothing left to spill; aggregate is already as small as we can make it.
- log.warn("[ToolResultStorage] aggregate still {} chars after spilling everything eligible",
+ // Nothing left to spill; remaining oversize is from excluded tools or
+ // already-spilled responses. Accept the over-budget state — better than
+ // breaking the agent's retrieval path.
+ log.warn("[ToolResultStorage] aggregate still {} chars after spilling everything eligible (excluded tools may push past budget)",
aggregate);
break;
}
diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml
index c579caa0..1d5ca662 100644
--- a/mateclaw-server/src/main/resources/application.yml
+++ b/mateclaw-server/src/main/resources/application.yml
@@ -162,6 +162,14 @@ mate:
per-turn-budget-chars: 16000
preview-head-chars: 800
storage-base-dir: ""
+ # Retrieval-style tools that must NEVER be spilled. Spilling read_file's
+ # output causes a recursion: the agent reads the spill path, that read also
+ # exceeds the threshold, gets spilled to a new path, agent reads that one,
+ # ad infinitum until MAX_TOOL_CALLS_PER_STEP is hit. Add MCP-provided
+ # readers here if they have the same role.
+ excluded-tools:
+ - read_file
+ - read_workspace_memory_file
conversation:
window:
# 测试时临时调低:2000 token ≈ 2000 中文字,3 轮对话即可触发压缩