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 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 <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 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<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]";
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) {
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) {
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<ToolResponseMessage.ToolResponse> 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;
}

View File

@ -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 轮对话即可触发压缩