mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(agent): structure-aware truncation to stop mid-JSON cuts inducing hallucination (#187)
This commit is contained in:
parent
43bbe26ff9
commit
03e68d3c74
@ -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");
|
||||
|
||||
@ -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 }} 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 }} 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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]";
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user