From c2c1cb5271b5cfc96c7aae00c75008da1630b125 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 19 Apr 2026 08:29:10 +0800 Subject: [PATCH] fix(tool-result): exempt retrieval tools from spill to prevent read-back recursion --- .../graph/executor/ToolResultProperties.java | 25 +++++++++++++ .../graph/executor/ToolResultStorage.java | 37 +++++++++++++++++-- .../src/main/resources/application.yml | 8 ++++ 3 files changed, 66 insertions(+), 4 deletions(-) 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 轮对话即可触发压缩