From e61b05bba0c1ab4ae4e15c6bb14699e7184ef42b Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 21 May 2026 16:26:24 +0800 Subject: [PATCH] fix(tool): read_file no longer returns empty content + infinite retry on oversized single lines (#190) --- .../vip/mate/tool/builtin/ReadFileTool.java | 62 +++++++++++-- .../src/main/resources/messages.properties | 3 + .../src/main/resources/messages_en.properties | 3 + .../builtin/ReadFileToolLargeLineTest.java | 86 +++++++++++++++++++ 4 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java index 6c582826..817a3b10 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java @@ -130,18 +130,44 @@ public class ReadFileTool { // 提取指定范围的行(转为 0-based) List 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 文件做容错处理 */ diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index a02c46f7..f60e53db 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -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} diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index 8aca1cf0..d5aa8220 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -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} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java new file mode 100644 index 00000000..dc54d941 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java @@ -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")); + } +}