diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index c0db1130..aba6c73a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -79,22 +79,70 @@ public class ToolExecutionExecutor { ); /** - * Layer 1 — hard truncation cap applied to every tool result before it - * reaches ToolResultStorage (Layer 2 spill) or the LLM prompt. + * Inline hard-truncate cap for a single tool result. Acts as the fallback + * when raw-first spill cannot run (storage disabled, tool excluded, body + * already at-or-below the spill threshold, or disk write failed). * - *
Two-level budget chain (RFC-008 / RFC-06 D-5): + *
Per-tool-result handling chain: *
- * raw tool result
- * → truncateToolResult(..., MAX_TOOL_RESULT_CHARS=8000) // Layer 1: hard cap
- * → persistIfOversized(..., perResultThresholdChars=16000) // Layer 2: spill to disk
- * → enforceTurnBudget(..., perTurnBudgetChars=32000) // Layer 3: per-turn aggregate
+ * raw tool result (full bytes)
+ * → spillRawOrTruncate(...)
+ * ├─ persistIfOversized(...) tries to write the raw body to disk
+ * │ when size > perResultThresholdChars and tool is not
+ * │ in the spill exclusion list. Returns a SPILL_MARKER preview
+ * │ on success, or the original string otherwise.
+ * └─ if no SPILL_MARKER on the return, truncateToolResult(...)
+ * caps inline to MAX_TOOL_RESULT_CHARS so a multi-MB raw
+ * body never enters the model prompt.
+ * → enforceTurnBudget(..., perTurnBudgetChars=32000) // per-turn aggregate
*
- * Layer 1 runs first and is intentionally kept at 8000 to prevent oversized
- * results from inflating the prompt. Layers 2/3 thresholds are configured in
- * {@link ToolResultProperties} and application.yml.
+ * Spill must see the RAW result so the full output is preserved on disk
+ * and the model can call {@code read_file} on the spill path. Truncating
+ * before spilling would write a pre-shortened blob to disk, defeating the
+ * "ground truth on disk" guarantee. {@link ToolResultProperties} controls
+ * the thresholds; this constant stays in code because it is the safety
+ * net for the failure case and should not vary by deployment.
*/
private static final int MAX_TOOL_RESULT_CHARS = 8000;
+ /**
+ * Raw-first spill: try to write the full result to disk via the spill
+ * store; only fall back to inline hard-truncate when no spill marker
+ * comes back. Caller distinguishes spill success from "returned
+ * unchanged" by checking {@link ToolResultStorage#SPILL_MARKER_PREFIX}
+ * on the returned string — otherwise an IO failure or under-threshold
+ * body would slip through indistinguishable from a successful spill,
+ * and a multi-MB raw body could end up in the model prompt.
+ *
+ * Package-private + static so the spill/truncate decision is unit + * testable in isolation from the rest of the executor. + * + * @param storage spill store; {@code null} skips the spill attempt + * @param maxTruncateChars fallback inline hard cap + * @param result raw tool output (may be {@code null}) + * @param toolName used in the spill preview header + * @param toolUseId unique within the conversation; becomes the file name + * @param conversationId spill files are scoped per conversation; blank/null falls back to "unknown" + * @param workspaceBasePath where the spill directory lives when set + * @return the SPILL_MARKER preview when spill succeeded, otherwise the + * original string (when ≤ threshold) or the inline-truncated string. + */ + static String spillRawOrTruncate(ToolResultStorage storage, int maxTruncateChars, + String result, String toolName, String toolUseId, + String conversationId, String workspaceBasePath) { + if (result == null) return null; + if (storage != null) { + String safeConv = conversationId != null && !conversationId.isEmpty() + ? conversationId : "unknown"; + String candidate = storage.persistIfOversized( + result, toolName, toolUseId, safeConv, workspaceBasePath); + if (candidate != null && candidate.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)) { + return candidate; + } + } + return truncateToolResult(result, maxTruncateChars); + } + /** 尾部错误模式检测 */ private static final java.util.regex.Pattern ERROR_TAIL_PATTERN = java.util.regex.Pattern.compile( "(?i)\\b(error|exception|traceback|failed|fatal|panic|stack.?trace|errno)\\b"); @@ -581,16 +629,13 @@ public class ToolExecutionExecutor { toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER); } - // RFC-008 Layer 1 first, then Layer 2 — match the non-replay path - // in executeSingleTool so behavior stays symmetric across approval - // replays. The caller-supplied conversationId scopes spill files - // into the same per-conversation directory layout. - result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS); - if (resultStorage != null && result != null) { - String spillConv = conversationId != null && !conversationId.isEmpty() ? conversationId : "unknown"; - result = resultStorage.persistIfOversized( - result, toolName, toolCall.id(), spillConv, workspaceBasePath); - } + // Raw-first spill, inline truncate as fallback. Symmetric with the + // non-replay path in executeSingleTool. The caller-supplied + // conversationId scopes spill files into the per-conversation + // directory layout. See spillRawOrTruncate javadoc for why the + // order matters. + result = spillRawOrTruncate(resultStorage, MAX_TOOL_RESULT_CHARS, + result, toolName, toolCall.id(), conversationId, workspaceBasePath); log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen, result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : ""); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true)); @@ -806,20 +851,16 @@ public class ToolExecutionExecutor { } } - // RFC-008 Layer 1: hard truncation cap to prevent oversized results - // from inflating the prompt. Runs FIRST (before spill) so the spill - // store doesn't need to handle multi-MB writes for run-of-the-mill - // greps that happen to spit out a long stdout. - result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS); - // RFC-008 Layer 2: spill oversized results to disk and replace - // with preview + path. Falls back to truncation when spilling is - // disabled or fails. Spill preserves the full output (read_file can - // retrieve it); the Layer 1 truncation above already capped the - // inline portion, so this layer mostly catches near-cap residues. - if (resultStorage != null && result != null) { - result = resultStorage.persistIfOversized( - result, toolName, pc.toolCall.id(), pc.conversationId, pc.workspaceBasePath); - } + // Raw-first spill: write the full output to disk and replace + // with preview + path so the model can call read_file for the + // ground truth. Fall back to inline truncate only when spilling + // is disabled, the tool is on the exclusion list, the body is + // already under the spill threshold, or the disk write fails. + // Truncating before spilling would persist a pre-shortened body + // to disk and silently lose data the model could otherwise + // recover. + result = spillRawOrTruncate(resultStorage, MAX_TOOL_RESULT_CHARS, + result, toolName, pc.toolCall.id(), pc.conversationId, pc.workspaceBasePath); log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen, result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : ""); events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true)); 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 d4c90dd4..41bb473d 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 @@ -40,12 +40,26 @@ public class ToolResultProperties { private boolean enabled = true; /** - * Layer 2 — a single tool result larger than this is spilled to disk. - * The executor evaluates this against the raw result before applying the - * final inline cap, so oversized content is preserved before it is shortened - * for the model request. + * Per-result spill threshold. A single tool result larger than this is + * spilled to disk and the in-context view is replaced with a short + * preview + path so the model can call {@code read_file} on demand. + * + *
Aligned with {@code ToolExecutionExecutor.MAX_TOOL_RESULT_CHARS} + * (8000): the executor now tries to spill the RAW result first; only + * when spilling is disabled, the tool is on {@link #excludedTools}, the + * body is under this threshold, or the disk write fails, does it fall + * back to truncating inline to 8000 chars. Keeping the threshold equal + * to the truncate cap yields a single semantic ladder — above the + * threshold means "preserved on disk", at-or-below means "stays inline + * verbatim". + * + *
If you want to keep more text inline before spilling, raise this + * value AND raise the executor's hard cap together; otherwise the + * 8000-char fallback truncate would silently shorten anything between + * this threshold and 8000 even when spill is disabled, defeating the + * intent. */ - private int perResultThresholdChars = 16000; // was 4000 — prevents WebSearch spill-to-disk + private int perResultThresholdChars = 8000; /** * Layer 3 — aggregate cap on combined response size in one tool turn. diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 1778126c..73da99a1 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -207,15 +207,17 @@ mate: per-category: shell: 120 web: 30 - # RFC-008 Phase 3: tool-result three-layer budget (per-result spill + per-turn aggregate budget). - # Layer 1 (per-tool cap) lives inside individual tools; Layer 2 spills oversized - # single results to disk; Layer 3 enforces an aggregate cap on the combined - # response size of one tool turn. The full output is preserved on disk and - # the in-context preview points the agent at the spill file (read_file tool). + # Tool-result budget (per-result spill + per-turn aggregate budget). + # The executor tries to spill the RAW result first so the full output is + # preserved on disk; the in-context preview points the agent at the spill + # file via read_file. When spill is disabled, the tool is on the exclusion + # list, the body is at or below the threshold, or the disk write fails, + # the executor falls back to inline hard-truncation to the same character + # cap. Per-turn aggregate caps the combined size across one tool turn. tool-result: enabled: true - per-result-threshold-chars: 16000 # was 4000 — prevents WebSearch spill-to-disk - per-turn-budget-chars: 32000 # was 16000 — headroom for multi-tool turns + per-result-threshold-chars: 8000 # aligned with executor hard cap; > this size → spill, ≤ → inline verbatim + per-turn-budget-chars: 32000 # headroom for multi-tool turns preview-head-chars: 800 excluded-tool-inline-chars: 2500 storage-base-dir: "" diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/LaneDExecutorAndConfigTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/LaneDExecutorAndConfigTest.java index e1a13147..a13047d9 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/LaneDExecutorAndConfigTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/LaneDExecutorAndConfigTest.java @@ -14,11 +14,14 @@ import java.util.concurrent.ExecutorService; import static org.junit.jupiter.api.Assertions.*; /** - * Tests for Lane D executor and config changes: + * Tests for the tool-result executor pipeline and its associated config. * *