From 03e68d3c744fb610fb5ae9fa496bd6899cf6893b Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 21 May 2026 15:13:07 +0800 Subject: [PATCH] fix(agent): structure-aware truncation to stop mid-JSON cuts inducing hallucination (#187) --- .../context/ConversationWindowManager.java | 12 +- .../agent/context/StructuredTruncator.java | 175 ++++++++++++++++++ .../graph/executor/ToolExecutionExecutor.java | 11 +- .../graph/executor/ToolResultStorage.java | 21 ++- .../observation/ObservationProcessor.java | 8 +- .../config/GraphObservationProperties.java | 4 +- .../src/main/resources/application.yml | 2 +- .../context/StructuredTruncatorTest.java | 138 ++++++++++++++ 8 files changed, 345 insertions(+), 26 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/context/StructuredTruncator.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/context/StructuredTruncatorTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java index 1e0c4550..07c44e6b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java @@ -923,10 +923,10 @@ public class ConversationWindowManager { } String data = r.responseData(); if (data != null && data.length() > 500) { - String head = data.substring(0, 200); - String tail = data.substring(data.length() - 200); + String marker = "\n...[trimmed " + data.length() + " chars; " + + StructuredTruncator.FIDELITY_NOTE + "]...\n"; newResponses.add(new ToolResponseMessage.ToolResponse( - r.id(), r.name(), head + "\n...[trimmed " + data.length() + " chars]...\n" + tail)); + r.id(), r.name(), StructuredTruncator.truncate(data, 200, 200, marker))); changed = true; } else { newResponses.add(r); @@ -1097,9 +1097,9 @@ public class ConversationWindowManager { String text = msg.getText(); if (text != null && text.length() > CONTENT_MAX) { - text = text.substring(0, CONTENT_HEAD) - + "\n...[截断 " + text.length() + " 字符]...\n" - + text.substring(text.length() - CONTENT_TAIL); + String marker = "\n...[truncated " + text.length() + " chars; " + + StructuredTruncator.FIDELITY_NOTE + "]...\n"; + text = StructuredTruncator.truncate(text, CONTENT_HEAD, CONTENT_TAIL, marker); } sb.append(role).append(": ").append(text != null ? text : "").append("\n\n"); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/StructuredTruncator.java b/mateclaw-server/src/main/java/vip/mate/agent/context/StructuredTruncator.java new file mode 100644 index 00000000..2171fbaa --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/StructuredTruncator.java @@ -0,0 +1,175 @@ +package vip.mate.agent.context; + +/** + * Boundary-aware text truncation. + * + *

Character-count truncation that lands inside a JSON value or string literal + * leaves the model a fragment like {@code {"name":"serv} — a shape that invites it + * to "repair" the structure by fabricating the omitted fields. When the input + * looks like JSON, this utility snaps each head/tail cut point to the nearest + * complete structural boundary (immediately after a {@code ,}, {@code }} or + * {@code ]} that is not inside a string), so a retained fragment always ends and + * begins between elements rather than in the middle of one. + * + *

Non-JSON input falls back to a plain character cut, and boundary snapping is + * only applied when it costs less than half the requested budget — so callers can + * use this unconditionally without ever losing more than a plain cut would. + */ +public final class StructuredTruncator { + + private StructuredTruncator() { + } + + private static final int[] NO_BOUNDARIES = new int[0]; + + /** + * Standard fidelity directive appended to truncation markers so the model + * treats omitted content as unknown rather than reconstructable. + */ + public static final String FIDELITY_NOTE = + "Do NOT infer or fabricate omitted content; retrieve the full data (e.g. read_file) " + + "or tell the user the result is incomplete."; + + /** + * Head-only slice: the first {@code maxHeadChars} characters, snapped back to + * a JSON boundary when one sits within the kept region. Returns the input + * unchanged when it is already short enough. + */ + public static String headSlice(String text, int maxHeadChars) { + if (text == null || maxHeadChars <= 0 || text.length() <= maxHeadChars) { + return text; + } + int[] bounds = boundaries(text); + int end = snapDown(bounds, maxHeadChars); + // Reject a boundary that throws away more than half the budget. + if (end < maxHeadChars / 2) { + end = maxHeadChars; + } + return text.substring(0, end); + } + + /** + * Head + marker + tail truncation. {@code headBudget} / {@code tailBudget} are + * upper bounds on each retained side; {@code marker} is inserted between them. + * The cut points snap to JSON boundaries when the input is JSON-like and the + * snap is cheap; otherwise plain character cuts are used. The result never + * exceeds {@code headBudget + marker.length() + tailBudget}. + * + * @return the input unchanged when it already fits both budgets + */ + public static String truncate(String text, int headBudget, int tailBudget, String marker) { + if (text == null) { + return null; + } + if (headBudget < 0) { + headBudget = 0; + } + if (tailBudget < 0) { + tailBudget = 0; + } + int len = text.length(); + if (len <= headBudget + tailBudget) { + return text; + } + String mk = marker == null ? "" : marker; + int[] bounds = boundaries(text); + + int headEnd = snapDown(bounds, headBudget); + if (headEnd < headBudget / 2) { + // No usable boundary near the head budget → plain cut. + headEnd = headBudget; + } + + int floor = len - tailBudget; + int tailStart = snapUp(bounds, floor); + if (tailStart > floor + tailBudget / 2) { + // Nearest boundary is so far forward the tail would shrink by half → plain cut. + tailStart = floor; + } + + if (tailStart <= headEnd) { + // Snapping collapsed the two regions into each other → plain, non-overlapping cut. + headEnd = Math.min(headBudget, len); + tailStart = Math.max(len - tailBudget, headEnd); + } + return text.substring(0, headEnd) + mk + text.substring(tailStart); + } + + /** + * Indices (in ascending order) at which the text may be split without + * severing a JSON token. A boundary index {@code i} marks the position + * immediately after a {@code ,}, {@code }} or {@code ]} that is not + * inside a string literal. Returns an empty array when the input does not + * look like JSON, which makes both snap helpers fall back to plain cuts. + */ + private static int[] boundaries(String text) { + int len = text.length(); + int start = 0; + while (start < len && Character.isWhitespace(text.charAt(start))) { + start++; + } + if (start >= len) { + return NO_BOUNDARIES; + } + char first = text.charAt(start); + if (first != '{' && first != '[') { + return NO_BOUNDARIES; + } + + int[] buf = new int[16]; + int n = 0; + boolean inString = false; + boolean escaped = false; + for (int i = start; i < len; i++) { + char c = text.charAt(i); + if (inString) { + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + inString = false; + } + continue; + } + if (c == '"') { + inString = true; + } else if (c == ',' || c == '}' || c == ']') { + if (n == buf.length) { + int[] grown = new int[buf.length * 2]; + System.arraycopy(buf, 0, grown, 0, n); + buf = grown; + } + buf[n++] = i + 1; + } + } + if (n == buf.length) { + return buf; + } + int[] out = new int[n]; + System.arraycopy(buf, 0, out, 0, n); + return out; + } + + /** Largest boundary {@code <= limit}, or 0 when none exists. */ + private static int snapDown(int[] bounds, int limit) { + int best = 0; + for (int b : bounds) { + if (b > limit) { + break; + } + best = b; + } + return best; + } + + /** Smallest boundary {@code >= floor}, or {@link Integer#MAX_VALUE} when none exists. */ + private static int snapUp(int[] bounds, int floor) { + for (int b : bounds) { + if (b >= floor) { + return b; + } + } + return Integer.MAX_VALUE; + } +} 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 aba6c73a..401ed6d7 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 @@ -10,6 +10,7 @@ import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.agent.AgentToolSet; import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.context.StructuredTruncator; import vip.mate.agent.graph.state.DirectToolOutput; import vip.mate.agent.graph.state.SourceEvidenceLedger; import vip.mate.approval.ApprovalWorkflowService; @@ -163,21 +164,21 @@ public class ToolExecutionExecutor { static String truncateToolResult(String result, int maxChars) { if (result == null || result.length() <= maxChars) return result; int rawLen = result.length(); - // 检测尾部 2000 字符是否含错误模式 + // Detect an error pattern in the trailing 2000 chars and bias toward the tail when present. String tailRegion = result.substring(Math.max(0, rawLen - 2000)); boolean errorDetected = ERROR_TAIL_PATTERN.matcher(tailRegion).find(); double headRatio = errorDetected ? 0.2 : 0.4; if (errorDetected) { log.info("[ToolExecutor] Error pattern detected in tail, preserving 80% tail (headRatio=0.2)"); } + String marker = "\n\n...[TRUNCATED: original " + rawLen + " chars, middle omitted. " + + StructuredTruncator.FIDELITY_NOTE + "]...\n\n"; int headLen = (int) (maxChars * headRatio); - int tailLen = maxChars - headLen - 80; + int tailLen = maxChars - headLen - marker.length(); if (tailLen <= 0) tailLen = maxChars / 2; log.info("[ToolExecutor] Truncated tool result from {} to {} chars (headRatio={})", rawLen, maxChars, headRatio); - return result.substring(0, headLen) - + "\n\n... [结果已截断,原始 " + rawLen + " 字符,保留首尾关键片段] ...\n\n" - + result.substring(rawLen - tailLen); + return StructuredTruncator.truncate(result, headLen, tailLen, marker); } private final Map toolCallbackMap; 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 88aeb301..426bf067 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 @@ -2,6 +2,7 @@ package vip.mate.agent.graph.executor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.ToolResponseMessage; +import vip.mate.agent.context.StructuredTruncator; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; @@ -230,16 +231,16 @@ public class ToolResultStorage { if (body == null || body.length() <= maxChars) { return body; } - int markerBudget = 120; - int available = Math.max(200, maxChars - markerBudget); + String marker = "\n\n... [tool result compacted for model context: tool=" + + toolName + ", original_chars=" + body.length() + ". " + + StructuredTruncator.FIDELITY_NOTE + "] ...\n\n"; + int available = Math.max(200, maxChars - marker.length()); int headLen = Math.max(100, (int) (available * 0.45)); int tailLen = Math.max(100, available - headLen); if (headLen + tailLen >= body.length()) { return body; } - String marker = "\n\n... [tool result compacted for model context: tool=" - + toolName + ", original_chars=" + body.length() + "] ...\n\n"; - return body.substring(0, headLen) + marker + body.substring(body.length() - tailLen); + return StructuredTruncator.truncate(body, headLen, tailLen, marker); } private static int aggregateSize(List responses) { @@ -251,14 +252,16 @@ public class ToolResultStorage { } private String buildPreview(String fullResult, String toolName, Path spillFile) { - int previewLen = Math.min(props.getPreviewHeadChars(), fullResult.length()); - String head = fullResult.substring(0, previewLen); + // Snap the preview to a complete JSON element so the model never sees a value + // severed mid-token (which invites it to fabricate the omitted fields). + String head = StructuredTruncator.headSlice(fullResult, props.getPreviewHeadChars()); return SPILL_MARKER_PREFIX + " tool=" + toolName + " full_chars=" + fullResult.length() + " path=" + spillFile.toAbsolutePath() - + "\n[Preview — first " + previewLen + " of " + fullResult.length() - + " chars. Use read_file with the path above to retrieve the rest.]\n" + + "\n[Preview — first " + head.length() + " of " + fullResult.length() + + " chars. The preview is INCOMPLETE: use read_file with the path above to " + + "retrieve the full result. Do NOT infer or fabricate the omitted content.]\n" + head + "\n…[truncated]"; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/observation/ObservationProcessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/observation/ObservationProcessor.java index ea0d4492..327580b7 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/observation/ObservationProcessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/observation/ObservationProcessor.java @@ -1,6 +1,7 @@ package vip.mate.agent.graph.observation; import lombok.extern.slf4j.Slf4j; +import vip.mate.agent.context.StructuredTruncator; import vip.mate.config.GraphObservationProperties; import java.util.List; @@ -94,12 +95,11 @@ public class ObservationProcessor { int headLen = (int) (available * effectiveHeadRatio); int tailLen = available - headLen; - String head = text.substring(0, headLen); - String tail = text.substring(originalLen - tailLen); + String result = StructuredTruncator.truncate(text, headLen, tailLen, marker); log.info("[Observation] Truncated from {} to {} chars (limit={}, headRatio={})", - originalLen, head.length() + tail.length(), maxLen, effectiveHeadRatio); - return head + marker + tail; + originalLen, result.length(), maxLen, effectiveHeadRatio); + return result; } /** diff --git a/mateclaw-server/src/main/java/vip/mate/config/GraphObservationProperties.java b/mateclaw-server/src/main/java/vip/mate/config/GraphObservationProperties.java index 718d931d..d6437a2d 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/GraphObservationProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/config/GraphObservationProperties.java @@ -34,7 +34,9 @@ public class GraphObservationProperties { private double headRatio = 0.4; /** Truncation marker; %d is replaced with original char count. */ - private String truncationMarker = "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n"; + private String truncationMarker = "\n\n...[TRUNCATED: %d chars total, middle omitted. Do NOT infer or " + + "fabricate omitted content; retrieve the full data (e.g. read_file) or tell the user " + + "the result is incomplete.]...\n\n"; /** Tail-keep ratio when an error pattern is detected at the tail (prefer keeping error info). */ private double errorTailRatio = 0.8; diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 1e548c86..d9d57fd0 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -209,7 +209,7 @@ mate: large-result-threshold: 32000 min-rounds-for-summarize: 25 head-ratio: 0.4 - truncation-marker: "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n" + truncation-marker: "\n\n...[TRUNCATED: %d chars total, middle omitted. Do NOT infer or fabricate omitted content; retrieve the full data (e.g. read_file) or tell the user the result is incomplete.]...\n\n" tool: timeout: default-timeout-seconds: 300 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/StructuredTruncatorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/StructuredTruncatorTest.java new file mode 100644 index 00000000..fe75725f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/StructuredTruncatorTest.java @@ -0,0 +1,138 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link StructuredTruncator} snaps JSON cut points to structural + * boundaries (never mid-token / mid-string) and degrades to plain cuts for + * non-JSON input, all while staying within the requested budget. + */ +class StructuredTruncatorTest { + + /** A 60-element array of uniform objects — the asset-inventory shape from the bug report. */ + private static String jsonArray(int rows) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < rows; i++) { + if (i > 0) { + sb.append(","); + } + sb.append("{\"id\":").append(i) + .append(",\"name\":\"server-").append(i) + .append("\",\"cpu\":8,\"mem\":\"64GB\",\"note\":\"comma,inside,string\"}"); + } + return sb.append("]").toString(); + } + + private static final String MARKER = "...[TRUNCATED]..."; + + @Test + @DisplayName("short input is returned unchanged") + void shortInputUnchanged() { + String s = jsonArray(2); + assertEquals(s, StructuredTruncator.truncate(s, 10_000, 10_000, MARKER)); + } + + @Test + @DisplayName("JSON head ends on a structural boundary, never mid-token") + void headSnapsToBoundary() { + String json = jsonArray(60); + String out = StructuredTruncator.truncate(json, 200, 200, MARKER); + + String head = out.substring(0, out.indexOf(MARKER)); + // The kept head must end right after a complete element/structure char. + char last = head.charAt(head.length() - 1); + assertTrue(last == ',' || last == '}' || last == ']', + "head must end on a JSON boundary, got: ..." + head.substring(Math.max(0, head.length() - 12))); + // And it must be balanced enough that no quote is left dangling open. + assertTrue(quotesBalancedIgnoringEscapes(head), + "head must not end inside a string literal: " + head); + } + + @Test + @DisplayName("JSON tail begins on a structural boundary, never mid-token") + void tailSnapsToBoundary() { + String json = jsonArray(60); + String out = StructuredTruncator.truncate(json, 200, 200, MARKER); + + String tail = out.substring(out.indexOf(MARKER) + MARKER.length()); + assertTrue(quotesBalancedIgnoringEscapes(tail), + "tail must not start inside a string literal: " + tail); + } + + @Test + @DisplayName("result never exceeds head + marker + tail budget") + void staysWithinBudget() { + String json = jsonArray(200); + String out = StructuredTruncator.truncate(json, 800, 800, MARKER); + assertTrue(out.length() <= 800 + MARKER.length() + 800, + "result length " + out.length() + " exceeded budget"); + assertTrue(out.length() < json.length(), "should actually have truncated"); + } + + @Test + @DisplayName("commas inside string values are not treated as boundaries") + void commasInStringsAreNotBoundaries() { + // A single object whose only comma-bearing content is inside a string. + String json = "{\"a\":\"x,y,z,looooooooooooooooooooooooooong,value\",\"b\":1}"; + String out = StructuredTruncator.truncate(json, 8, 8, MARKER); + String head = out.substring(0, out.indexOf(MARKER)); + // The head budget (8) lands inside the quoted value; since the only commas + // are inside the string, no cheap boundary exists → plain cut, but it must + // not have falsely split on an in-string comma earlier than budget. + assertTrue(head.length() <= 8, "head must respect budget when no real boundary exists"); + } + + @Test + @DisplayName("non-JSON text falls back to plain head+tail cut") + void nonJsonPlainCut() { + String text = "x".repeat(5000); + String out = StructuredTruncator.truncate(text, 100, 100, MARKER); + assertEquals("x".repeat(100) + MARKER + "x".repeat(100), out); + } + + @Test + @DisplayName("headSlice snaps a JSON preview to a complete element") + void headSliceSnaps() { + String json = jsonArray(60); + String preview = StructuredTruncator.headSlice(json, 200); + assertTrue(preview.length() <= 200); + char last = preview.charAt(preview.length() - 1); + assertTrue(last == ',' || last == '}' || last == ']', + "preview must end on a JSON boundary, got: " + preview); + assertFalse(preview.equals(json)); + } + + @Test + @DisplayName("null input is tolerated") + void nullSafe() { + assertEquals(null, StructuredTruncator.truncate(null, 10, 10, MARKER)); + assertEquals(null, StructuredTruncator.headSlice(null, 10)); + } + + /** True when double-quotes (ignoring backslash-escaped ones) are balanced, i.e. the + * fragment does not end while still inside a string literal. */ + private static boolean quotesBalancedIgnoringEscapes(String s) { + boolean inString = false; + boolean escaped = false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (inString) { + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + inString = false; + } + } else if (c == '"') { + inString = true; + } + } + return !inString; + } +}