mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
fix(executor): spill raw tool result before falling back to inline truncate
This commit is contained in:
parent
2ef806aa64
commit
8f49b0ec92
@ -79,22 +79,70 @@ public class ToolExecutionExecutor {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layer 1 — hard truncation cap applied to every tool result before it
|
* Inline hard-truncate cap for a single tool result. Acts as the fallback
|
||||||
* reaches ToolResultStorage (Layer 2 spill) or the LLM prompt.
|
* when raw-first spill cannot run (storage disabled, tool excluded, body
|
||||||
|
* already at-or-below the spill threshold, or disk write failed).
|
||||||
*
|
*
|
||||||
* <p>Two-level budget chain (RFC-008 / RFC-06 D-5):
|
* <p>Per-tool-result handling chain:
|
||||||
* <pre>
|
* <pre>
|
||||||
* raw tool result
|
* raw tool result (full bytes)
|
||||||
* → truncateToolResult(..., MAX_TOOL_RESULT_CHARS=8000) // Layer 1: hard cap
|
* → spillRawOrTruncate(...)
|
||||||
* → persistIfOversized(..., perResultThresholdChars=16000) // Layer 2: spill to disk
|
* ├─ persistIfOversized(...) tries to write the raw body to disk
|
||||||
* → enforceTurnBudget(..., perTurnBudgetChars=32000) // Layer 3: per-turn aggregate
|
* │ 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
|
||||||
* </pre>
|
* </pre>
|
||||||
* Layer 1 runs first and is intentionally kept at 8000 to prevent oversized
|
* Spill must see the RAW result so the full output is preserved on disk
|
||||||
* results from inflating the prompt. Layers 2/3 thresholds are configured in
|
* and the model can call {@code read_file} on the spill path. Truncating
|
||||||
* {@link ToolResultProperties} and application.yml.
|
* 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;
|
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.
|
||||||
|
*
|
||||||
|
* <p>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(
|
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");
|
"(?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);
|
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
||||||
}
|
}
|
||||||
|
|
||||||
// RFC-008 Layer 1 first, then Layer 2 — match the non-replay path
|
// Raw-first spill, inline truncate as fallback. Symmetric with the
|
||||||
// in executeSingleTool so behavior stays symmetric across approval
|
// non-replay path in executeSingleTool. The caller-supplied
|
||||||
// replays. The caller-supplied conversationId scopes spill files
|
// conversationId scopes spill files into the per-conversation
|
||||||
// into the same per-conversation directory layout.
|
// directory layout. See spillRawOrTruncate javadoc for why the
|
||||||
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
|
// order matters.
|
||||||
if (resultStorage != null && result != null) {
|
result = spillRawOrTruncate(resultStorage, MAX_TOOL_RESULT_CHARS,
|
||||||
String spillConv = conversationId != null && !conversationId.isEmpty() ? conversationId : "unknown";
|
result, toolName, toolCall.id(), conversationId, workspaceBasePath);
|
||||||
result = resultStorage.persistIfOversized(
|
|
||||||
result, toolName, toolCall.id(), spillConv, workspaceBasePath);
|
|
||||||
}
|
|
||||||
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
|
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
|
||||||
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true));
|
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
|
// Raw-first spill: write the full output to disk and replace
|
||||||
// from inflating the prompt. Runs FIRST (before spill) so the spill
|
// with preview + path so the model can call read_file for the
|
||||||
// store doesn't need to handle multi-MB writes for run-of-the-mill
|
// ground truth. Fall back to inline truncate only when spilling
|
||||||
// greps that happen to spit out a long stdout.
|
// is disabled, the tool is on the exclusion list, the body is
|
||||||
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
|
// already under the spill threshold, or the disk write fails.
|
||||||
// RFC-008 Layer 2: spill oversized results to disk and replace
|
// Truncating before spilling would persist a pre-shortened body
|
||||||
// with preview + path. Falls back to truncation when spilling is
|
// to disk and silently lose data the model could otherwise
|
||||||
// disabled or fails. Spill preserves the full output (read_file can
|
// recover.
|
||||||
// retrieve it); the Layer 1 truncation above already capped the
|
result = spillRawOrTruncate(resultStorage, MAX_TOOL_RESULT_CHARS,
|
||||||
// inline portion, so this layer mostly catches near-cap residues.
|
result, toolName, pc.toolCall.id(), pc.conversationId, pc.workspaceBasePath);
|
||||||
if (resultStorage != null && result != null) {
|
|
||||||
result = resultStorage.persistIfOversized(
|
|
||||||
result, toolName, pc.toolCall.id(), pc.conversationId, pc.workspaceBasePath);
|
|
||||||
}
|
|
||||||
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
|
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
|
||||||
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
||||||
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true));
|
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true));
|
||||||
|
|||||||
@ -40,12 +40,26 @@ public class ToolResultProperties {
|
|||||||
private boolean enabled = true;
|
private boolean enabled = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layer 2 — a single tool result larger than this is spilled to disk.
|
* Per-result spill threshold. A single tool result larger than this is
|
||||||
* The executor evaluates this against the raw result before applying the
|
* spilled to disk and the in-context view is replaced with a short
|
||||||
* final inline cap, so oversized content is preserved before it is shortened
|
* preview + path so the model can call {@code read_file} on demand.
|
||||||
* for the model request.
|
*
|
||||||
|
* <p>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".
|
||||||
|
*
|
||||||
|
* <p>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.
|
* Layer 3 — aggregate cap on combined response size in one tool turn.
|
||||||
|
|||||||
@ -207,15 +207,17 @@ mate:
|
|||||||
per-category:
|
per-category:
|
||||||
shell: 120
|
shell: 120
|
||||||
web: 30
|
web: 30
|
||||||
# RFC-008 Phase 3: tool-result three-layer budget (per-result spill + per-turn aggregate budget).
|
# Tool-result budget (per-result spill + per-turn aggregate budget).
|
||||||
# Layer 1 (per-tool cap) lives inside individual tools; Layer 2 spills oversized
|
# The executor tries to spill the RAW result first so the full output is
|
||||||
# single results to disk; Layer 3 enforces an aggregate cap on the combined
|
# preserved on disk; the in-context preview points the agent at the spill
|
||||||
# response size of one tool turn. The full output is preserved on disk and
|
# file via read_file. When spill is disabled, the tool is on the exclusion
|
||||||
# the in-context preview points the agent at the spill file (read_file tool).
|
# 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:
|
tool-result:
|
||||||
enabled: true
|
enabled: true
|
||||||
per-result-threshold-chars: 16000 # was 4000 — prevents WebSearch spill-to-disk
|
per-result-threshold-chars: 8000 # aligned with executor hard cap; > this size → spill, ≤ → inline verbatim
|
||||||
per-turn-budget-chars: 32000 # was 16000 — headroom for multi-tool turns
|
per-turn-budget-chars: 32000 # headroom for multi-tool turns
|
||||||
preview-head-chars: 800
|
preview-head-chars: 800
|
||||||
excluded-tool-inline-chars: 2500
|
excluded-tool-inline-chars: 2500
|
||||||
storage-base-dir: ""
|
storage-base-dir: ""
|
||||||
|
|||||||
@ -14,11 +14,14 @@ import java.util.concurrent.ExecutorService;
|
|||||||
import static org.junit.jupiter.api.Assertions.*;
|
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.
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>D-4: ToolExecutionExecutor uses virtual thread executor</li>
|
* <li>Virtual-thread tool executor is wired with named carrier threads.</li>
|
||||||
* <li>D-5: ToolResultProperties defaults updated to 16000/32000</li>
|
* <li>{@link ToolResultProperties} defaults stay aligned with the executor
|
||||||
|
* inline hard cap so spill and truncate share one semantic threshold.</li>
|
||||||
|
* <li>{@link ToolExecutionExecutor#spillRawOrTruncate} attempts spill on the
|
||||||
|
* raw body first and falls back to truncation only when spill cannot run.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
class LaneDExecutorAndConfigTest {
|
class LaneDExecutorAndConfigTest {
|
||||||
@ -68,19 +71,19 @@ class LaneDExecutorAndConfigTest {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
@DisplayName("D-5: ToolResultProperties defaults updated")
|
@DisplayName("ToolResultProperties defaults")
|
||||||
class ToolResultPropertiesDefaultsTests {
|
class ToolResultPropertiesDefaultsTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("perResultThresholdChars default is 16000 (was 4000)")
|
@DisplayName("perResultThresholdChars default aligns with executor hard cap (8000)")
|
||||||
void perResultThresholdCharsDefault() {
|
void perResultThresholdCharsDefault() {
|
||||||
ToolResultProperties props = new ToolResultProperties();
|
ToolResultProperties props = new ToolResultProperties();
|
||||||
assertEquals(16000, props.getPerResultThresholdChars(),
|
assertEquals(8000, props.getPerResultThresholdChars(),
|
||||||
"Default perResultThresholdChars should be 16000");
|
"Default perResultThresholdChars should equal the executor's MAX_TOOL_RESULT_CHARS=8000");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("perTurnBudgetChars default is 32000 (was 16000)")
|
@DisplayName("perTurnBudgetChars default is 32000")
|
||||||
void perTurnBudgetCharsDefault() {
|
void perTurnBudgetCharsDefault() {
|
||||||
ToolResultProperties props = new ToolResultProperties();
|
ToolResultProperties props = new ToolResultProperties();
|
||||||
assertEquals(32000, props.getPerTurnBudgetChars(),
|
assertEquals(32000, props.getPerTurnBudgetChars(),
|
||||||
@ -101,15 +104,125 @@ class LaneDExecutorAndConfigTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Large results above 16000 still trigger spill (threshold boundary)")
|
@DisplayName("Per-result threshold matches the executor inline hard cap so spill and truncate share one ladder")
|
||||||
void thresholdBoundary() {
|
void thresholdMatchesExecutorHardCap() throws Exception {
|
||||||
ToolResultProperties props = new ToolResultProperties();
|
ToolResultProperties props = new ToolResultProperties();
|
||||||
// Results <= 16000 should NOT spill
|
Field field = ToolExecutionExecutor.class.getDeclaredField("MAX_TOOL_RESULT_CHARS");
|
||||||
assertTrue(15000 <= props.getPerResultThresholdChars(),
|
field.setAccessible(true);
|
||||||
"A 15000-char result should be within threshold");
|
int hardCap = (int) field.get(null);
|
||||||
// Results > 16000 should spill
|
assertEquals(hardCap, props.getPerResultThresholdChars(),
|
||||||
assertTrue(17000 > props.getPerResultThresholdChars(),
|
"perResultThresholdChars must equal MAX_TOOL_RESULT_CHARS; misalignment would silently shorten "
|
||||||
"A 17000-char result should exceed threshold");
|
+ "bodies between the two values when spill is disabled.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Raw-first spill ordering — the critical issue #110 fix
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("ToolExecutionExecutor.spillRawOrTruncate: raw body reaches disk before the inline cap")
|
||||||
|
class SpillOrTruncateOrderingTests {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private ToolResultStorage storage(int threshold, List<String> excluded) {
|
||||||
|
ToolResultProperties props = new ToolResultProperties();
|
||||||
|
props.setStorageBaseDir(tempDir.toString());
|
||||||
|
props.setPerResultThresholdChars(threshold);
|
||||||
|
props.setPreviewHeadChars(120);
|
||||||
|
if (excluded != null) props.setExcludedTools(excluded);
|
||||||
|
return new ToolResultStorage(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("raw body > threshold → spill writes full original bytes to disk and returns preview")
|
||||||
|
void rawOverThresholdSpillsFullContent() throws Exception {
|
||||||
|
ToolResultStorage st = storage(1000, null);
|
||||||
|
String raw = "0123456789\n".repeat(2000); // ~22000 chars, well over both threshold AND hard cap
|
||||||
|
int rawLen = raw.length();
|
||||||
|
|
||||||
|
String out = ToolExecutionExecutor.spillRawOrTruncate(
|
||||||
|
st, 8000, raw, "web_search", "call-1", "conv-x", tempDir.toString());
|
||||||
|
|
||||||
|
assertTrue(out.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX),
|
||||||
|
"should return a spill preview when raw exceeds threshold");
|
||||||
|
assertTrue(out.contains("full_chars=" + rawLen),
|
||||||
|
"preview header must report the original size, proving the raw bytes were what we measured");
|
||||||
|
|
||||||
|
// The file on disk should be the FULL raw body — not the 8000-char truncate.
|
||||||
|
// Path is encoded inside the preview as "path=/abs/path".
|
||||||
|
java.util.regex.Matcher m = java.util.regex.Pattern.compile("path=(\\S+)").matcher(out);
|
||||||
|
assertTrue(m.find(), "preview must include path=...");
|
||||||
|
Path spillFile = Path.of(m.group(1));
|
||||||
|
assertTrue(java.nio.file.Files.exists(spillFile), "spill file should have been created");
|
||||||
|
String fileContent = java.nio.file.Files.readString(spillFile);
|
||||||
|
assertEquals(rawLen, fileContent.length(),
|
||||||
|
"spill file must contain the full raw body, not a pre-truncated copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("raw body > threshold but tool is on exclusion list → no spill, inline hard cap")
|
||||||
|
void rawOverThresholdExcludedToolTruncatesOnly() {
|
||||||
|
ToolResultStorage st = storage(1000, List.of("read_file"));
|
||||||
|
String raw = "x".repeat(20000);
|
||||||
|
|
||||||
|
String out = ToolExecutionExecutor.spillRawOrTruncate(
|
||||||
|
st, 8000, raw, "read_file", "call-1", "conv-x", tempDir.toString());
|
||||||
|
|
||||||
|
assertFalse(out.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX),
|
||||||
|
"excluded tool must not be spilled");
|
||||||
|
assertTrue(out.length() <= 8000,
|
||||||
|
"excluded body still must fit the inline hard cap (was " + out.length() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("raw body ≤ threshold → returned unchanged, no spill, no truncation marker added")
|
||||||
|
void rawUnderThresholdInlineVerbatim() {
|
||||||
|
ToolResultStorage st = storage(1000, null);
|
||||||
|
String raw = "small body";
|
||||||
|
|
||||||
|
String out = ToolExecutionExecutor.spillRawOrTruncate(
|
||||||
|
st, 8000, raw, "web_search", "call-1", "conv-x", tempDir.toString());
|
||||||
|
|
||||||
|
assertEquals(raw, out, "small bodies should pass through untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("storage null → falls back to inline hard cap, never crashes")
|
||||||
|
void nullStorageFallsBackToTruncate() {
|
||||||
|
String raw = "x".repeat(20000);
|
||||||
|
|
||||||
|
String out = ToolExecutionExecutor.spillRawOrTruncate(
|
||||||
|
null, 8000, raw, "web_search", "call-1", "conv-x", tempDir.toString());
|
||||||
|
|
||||||
|
assertFalse(out.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX));
|
||||||
|
assertTrue(out.length() <= 8000,
|
||||||
|
"with no storage, body must still fit the inline hard cap");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("null result stays null (no NPE)")
|
||||||
|
void nullResultStaysNull() {
|
||||||
|
ToolResultStorage st = storage(1000, null);
|
||||||
|
String out = ToolExecutionExecutor.spillRawOrTruncate(
|
||||||
|
st, 8000, null, "web_search", "call-1", "conv-x", tempDir.toString());
|
||||||
|
assertNull(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("blank conversationId is replaced with a safe 'unknown' bucket so spill still lands on disk")
|
||||||
|
void blankConversationIdRoutesToUnknownBucket() {
|
||||||
|
ToolResultStorage st = storage(1000, null);
|
||||||
|
String raw = "y".repeat(20000);
|
||||||
|
|
||||||
|
String out = ToolExecutionExecutor.spillRawOrTruncate(
|
||||||
|
st, 8000, raw, "web_search", "call-1", "", tempDir.toString());
|
||||||
|
|
||||||
|
assertTrue(out.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX),
|
||||||
|
"blank conversationId must not stop spill — caller can be the legacy executePreApproved path");
|
||||||
|
assertTrue(out.contains("unknown"), "spill path should land under the 'unknown' bucket");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user