fix(tool): read_file no longer returns empty content + infinite retry on oversized single lines (#190)

This commit is contained in:
matevip 2026-05-21 16:26:24 +08:00
parent 49d9eccaec
commit e61b05bba0
4 changed files with 148 additions and 6 deletions

View File

@ -130,18 +130,44 @@ public class ReadFileTool {
// 提取指定范围的行转为 0-based
List<String> selectedLines = allLines.subList(start - 1, end);
// 截断控制
// Truncation control. Each output line carries a "%6d\t" prefix and a
// trailing newline, so the budget available for the line's own text is
// the remaining byte budget minus that overhead.
StringBuilder sb = new StringBuilder();
int linesRead = 0;
boolean truncated = false;
int maxLines = Math.min(selectedLines.size(), DEFAULT_MAX_LINES);
boolean lineTruncated = false;
int truncatedLineNum = 0;
for (int i = 0; i < selectedLines.size(); i++) {
String line = selectedLines.get(i);
int lineNum = start + i;
if (linesRead >= DEFAULT_MAX_LINES) {
truncated = true;
break;
}
String numberedLine = String.format("%6d\t%s\n", lineNum, line);
if (sb.length() + numberedLine.length() > MAX_OUTPUT_BYTES || linesRead >= DEFAULT_MAX_LINES) {
if (sb.length() + numberedLine.length() > MAX_OUTPUT_BYTES) {
// This line does not fit in the remaining budget. Normally we
// stop and let the caller continue from the next line. But when
// a single line is itself larger than the whole budget and we
// have read nothing yet, stopping here would return empty
// content with linesRead=0 and the suggested continuation
// startLine never advances, producing an infinite retry loop.
// Guarantee progress by emitting as much of this oversized line
// as fits, flagged as truncated, then advancing past it.
if (linesRead == 0) {
String prefix = String.format("%6d\t", lineNum);
String marker = i18n.msg("tool.read_file.line_truncated_marker");
int budget = MAX_OUTPUT_BYTES - prefix.length() - marker.length() - 1; // -1 for '\n'
String clipped = safeTruncate(line, Math.max(0, budget));
sb.append(prefix).append(clipped).append(marker).append('\n');
linesRead++;
lineTruncated = true;
truncatedLineNum = lineNum;
}
truncated = true;
break;
}
@ -155,10 +181,18 @@ public class ReadFileTool {
result.set("content", sb.toString());
if (truncated) {
int nextStart = start + linesRead;
result.set("truncated", true);
result.set("message", "输出已截断(最多 " + DEFAULT_MAX_LINES + " 行 / " + (MAX_OUTPUT_BYTES / 1024)
+ "KB。使用 startLine=" + nextStart + " 继续读取。");
int kb = MAX_OUTPUT_BYTES / 1024;
if (lineTruncated) {
// The oversized line was clipped in place; line ranges cannot
// recover its tail, so do not advertise a startLine that would
// silently skip the remainder.
result.set("lineTruncated", true);
result.set("message", i18n.msg("tool.read_file.line_truncated", truncatedLineNum, kb));
} else {
int nextStart = start + linesRead;
result.set("message", i18n.msg("tool.read_file.truncated", DEFAULT_MAX_LINES, kb, nextStart));
}
} else {
result.set("truncated", false);
}
@ -196,6 +230,22 @@ public class ReadFileTool {
return sb.toString();
}
/**
* Truncate a string to at most {@code maxChars} characters without splitting
* a UTF-16 surrogate pair. If the cut would land between a high and low
* surrogate, drop the dangling high surrogate so the result stays valid.
*/
private static String safeTruncate(String s, int maxChars) {
if (s.length() <= maxChars) {
return s;
}
int end = maxChars;
if (end > 0 && Character.isHighSurrogate(s.charAt(end - 1))) {
end--;
}
return s.substring(0, end);
}
/**
* UTF-8 读取文件全部行对非 UTF-8 文件做容错处理
*/

View File

@ -59,6 +59,9 @@ tool.read_file.error.not_readable=\u6587\u4ef6\u4e0d\u53ef\u8bfb: {0}
tool.read_file.error.start_exceeds=\u8d77\u59cb\u884c {0} \u8d85\u51fa\u6587\u4ef6\u603b\u884c\u6570 {1}
tool.read_file.error.start_gt_end=\u8d77\u59cb\u884c {0} \u5927\u4e8e\u7ed3\u675f\u884c {1}
tool.read_file.error.read_exception=\u8bfb\u53d6\u6587\u4ef6\u5f02\u5e38: {0}
tool.read_file.truncated=\u8f93\u51fa\u5df2\u622a\u65ad\uff08\u6700\u591a {0} \u884c / {1}KB\uff09\u3002\u4f7f\u7528 startLine={2} \u7ee7\u7eed\u8bfb\u53d6\u3002
tool.read_file.line_truncated_marker= ...[\u672c\u884c\u8fc7\u957f\uff0c\u5df2\u622a\u65ad]
tool.read_file.line_truncated=\u7b2c {0} \u884c\u957f\u5ea6\u8d85\u8fc7\u5355\u6b21\u8f93\u51fa\u4e0a\u9650\uff08{1}KB\uff09\uff0c\u5df2\u622a\u65ad\u663e\u793a\u3002\u8be5\u884c\u5b8c\u6574\u5185\u5bb9\u65e0\u6cd5\u901a\u8fc7\u884c\u53f7\u8303\u56f4\u9010\u6b65\u8bfb\u53d6\uff1b\u5982\u9700\u5b8c\u6574\u6570\u636e\u8bf7\u4f7f\u7528 execute_shell_command\u3002\u4e0d\u8981\u63a8\u65ad\u6216\u865a\u6784\u88ab\u622a\u65ad\u7684\u5185\u5bb9\u3002
tool.write_file.error.path_empty=\u6587\u4ef6\u8def\u5f84\u4e0d\u80fd\u4e3a\u7a7a
tool.write_file.error.is_directory=\u8def\u5f84\u662f\u4e00\u4e2a\u5df2\u6709\u76ee\u5f55\uff0c\u65e0\u6cd5\u4f5c\u4e3a\u6587\u4ef6\u5199\u5165: {0}
tool.write_file.error.write_exception=\u5199\u5165\u6587\u4ef6\u5f02\u5e38: {0}

View File

@ -59,6 +59,9 @@ tool.read_file.error.not_readable=File is not readable: {0}
tool.read_file.error.start_exceeds=Start line {0} exceeds total lines {1}
tool.read_file.error.start_gt_end=Start line {0} is greater than end line {1}
tool.read_file.error.read_exception=Read file exception: {0}
tool.read_file.truncated=Output truncated (max {0} lines / {1}KB). Use startLine={2} to continue reading.
tool.read_file.line_truncated_marker= ...[line too long, truncated]
tool.read_file.line_truncated=Line {0} exceeds the single-output limit ({1}KB) and was truncated. Its full content cannot be read via line ranges; use execute_shell_command if you need the complete data. Do NOT infer or fabricate the omitted content.
tool.write_file.error.path_empty=File path cannot be empty
tool.write_file.error.is_directory=Path is an existing directory, cannot write as file: {0}
tool.write_file.error.write_exception=Write file exception: {0}

View File

@ -0,0 +1,86 @@
package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import vip.mate.i18n.I18nService;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
/**
* Regression test for the single-line oversized-file bug: a file whose only
* line exceeds the output byte budget used to return empty content with
* readLines=0 and a continuation hint that never advanced an infinite retry
* loop. The tool must instead return a clipped, clearly-flagged result and make
* progress.
*/
class ReadFileToolLargeLineTest {
private ReadFileTool tool;
@BeforeEach
void setUp() {
// Default answer echoes msg(key, ...) back as the key, so assertions stay
// locale-agnostic and the answer covers every varargs arity uniformly.
I18nService i18n = mock(I18nService.class, inv ->
"msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null);
tool = new ReadFileTool(i18n);
// Ensure no workspace boundary is active so absolute temp paths validate.
ToolExecutionContext.clear();
}
@AfterEach
void tearDown() {
ToolExecutionContext.clear();
}
@Test
@DisplayName("single line larger than the 30KB budget returns clipped content, not empty + infinite loop")
void singleOversizedLine_returnsClippedContent(@TempDir Path dir) throws Exception {
// ~40KB single-line JSON array on one physical line.
StringBuilder json = new StringBuilder("[");
for (int i = 0; i < 4000; i++) {
if (i > 0) json.append(',');
json.append("\"item-").append(i).append("\"");
}
json.append(']');
Path file = dir.resolve("big.json");
Files.writeString(file, json.toString(), StandardCharsets.UTF_8);
String raw = tool.read_file(file.toString(), null, null, null);
JSONObject res = JSONUtil.parseObj(raw);
assertFalse(res.getBool("error", false), "should not be an error result");
assertTrue(res.getBool("truncated"), "should be marked truncated");
assertTrue(res.getBool("lineTruncated", false), "should flag in-line truncation");
// The bug: content was empty and readLines was 0.
assertEquals(1, res.getInt("readLines"), "must count the clipped line as read");
assertTrue(res.getStr("content").length() > 1000, "content must carry the clipped line, not be empty");
assertEquals("tool.read_file.line_truncated", res.getStr("message"));
}
@Test
@DisplayName("normal multi-line file reads fully without truncation")
void smallFile_readsFully(@TempDir Path dir) throws Exception {
Path file = dir.resolve("small.txt");
Files.writeString(file, "alpha\nbeta\ngamma\n", StandardCharsets.UTF_8);
String raw = tool.read_file(file.toString(), null, null, null);
JSONObject res = JSONUtil.parseObj(raw);
assertFalse(res.getBool("truncated"), "small file should not truncate");
assertEquals(3, res.getInt("readLines"));
assertTrue(res.getStr("content").contains("beta"));
}
}