mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(tool): read_file no longer returns empty content + infinite retry on oversized single lines (#190)
This commit is contained in:
parent
49d9eccaec
commit
e61b05bba0
@ -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 文件做容错处理
|
||||
*/
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user