fix(tool-result): exempt retrieval tools from spill to prevent read-back recursion

This commit is contained in:
matevip 2026-04-19 08:29:10 +08:00
parent 40bbde1278
commit c2c1cb5271
3 changed files with 66 additions and 4 deletions

View File

@ -2,6 +2,9 @@ package vip.mate.agent.graph.executor;
import org.springframework.boot.context.properties.ConfigurationProperties; 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). * Configuration for the tool-result three-layer budget (RFC-008 Phase 3).
* *
@ -51,6 +54,18 @@ public class ToolResultProperties {
*/ */
private String storageBaseDir = ""; private String storageBaseDir = "";
/**
* Tools whose results must NEVER be spilled. These are the tools the agent
* uses to <i>retrieve</i> 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}.
*
* <p>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.</p>
*/
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
public boolean isEnabled() { return enabled; } public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; }
@ -73,4 +88,14 @@ public class ToolResultProperties {
public void setStorageBaseDir(String storageBaseDir) { public void setStorageBaseDir(String storageBaseDir) {
this.storageBaseDir = storageBaseDir == null ? "" : storageBaseDir; this.storageBaseDir = storageBaseDir == null ? "" : storageBaseDir;
} }
public List<String> getExcludedTools() { return excludedTools; }
public void setExcludedTools(List<String> excludedTools) {
this.excludedTools = excludedTools == null ? List.of() : excludedTools;
}
/** O(1) membership test for the exclusion list, used on every tool result. */
public Set<String> excludedToolsSet() {
return Set.copyOf(excludedTools);
}
} }

View File

@ -52,9 +52,29 @@ public class ToolResultStorage {
public static final String SPILL_MARKER_PREFIX = "[mate-tool-result-spill]"; public static final String SPILL_MARKER_PREFIX = "[mate-tool-result-spill]";
private final ToolResultProperties props; private final ToolResultProperties props;
/** Cached at construction; refreshed lazily if the underlying list mutates (rare). */
private volatile java.util.Set<String> excludedToolsSnapshot;
public ToolResultStorage(ToolResultProperties props) { public ToolResultStorage(ToolResultProperties props) {
this.props = 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<String> snap = excludedToolsSnapshot;
java.util.Set<String> 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) { if (!props.isEnabled() || result == null) {
return result; return result;
} }
if (isExcluded(toolName)) {
// Retrieval-style tool never spill, would cause read-back recursion.
return result;
}
if (result.length() <= props.getPerResultThresholdChars()) { if (result.length() <= props.getPerResultThresholdChars()) {
return result; return result;
} }
@ -115,20 +139,25 @@ public class ToolResultStorage {
List<ToolResponseMessage.ToolResponse> mutable = new ArrayList<>(responses); List<ToolResponseMessage.ToolResponse> mutable = new ArrayList<>(responses);
while (aggregate > budget) { 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 targetIdx = -1;
int targetLen = -1; int targetLen = -1;
for (int i = 0; i < mutable.size(); i++) { 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 (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
if (isExcluded(r.name())) continue; // retrieval tools must not be spilled
if (body.length() > targetLen) { if (body.length() > targetLen) {
targetLen = body.length(); targetLen = body.length();
targetIdx = i; targetIdx = i;
} }
} }
if (targetIdx < 0) { if (targetIdx < 0) {
// Nothing left to spill; aggregate is already as small as we can make it. // Nothing left to spill; remaining oversize is from excluded tools or
log.warn("[ToolResultStorage] aggregate still {} chars after spilling everything eligible", // 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); aggregate);
break; break;
} }

View File

@ -162,6 +162,14 @@ mate:
per-turn-budget-chars: 16000 per-turn-budget-chars: 16000
preview-head-chars: 800 preview-head-chars: 800
storage-base-dir: "" 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: conversation:
window: window:
# 测试时临时调低2000 token ≈ 2000 中文字3 轮对话即可触发压缩 # 测试时临时调低2000 token ≈ 2000 中文字3 轮对话即可触发压缩