fix(agent): structure-aware truncation to stop mid-JSON cuts inducing hallucination (#187)

This commit is contained in:
matevip 2026-05-21 15:13:07 +08:00
parent 43bbe26ff9
commit 03e68d3c74
8 changed files with 345 additions and 26 deletions

View File

@ -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");

View File

@ -0,0 +1,175 @@
package vip.mate.agent.context;
/**
* Boundary-aware text truncation.
*
* <p>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 &#125;} 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.
*
* <p>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 &#125;} 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;
}
}

View File

@ -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<String, ToolCallback> toolCallbackMap;

View File

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

View File

@ -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;
}
/**

View File

@ -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;

View File

@ -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

View File

@ -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;
}
}