mateclaw/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java

276 lines
12 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 内置工具:读取文件内容
* <p>
* 支持按行范围读取,自动截断超大输出。
* 支持 line-based range、smart truncation、continuation hints。
* <p>
* 重要限制:此工具仅支持文本文件,不处理 PDF/Office 文档。
* 对于 .pdf/.docx/.xlsx/.pptx 等文档,请使用 extract_document_text 工具。
*
* @author MateClaw Team
*/
@Slf4j
@Component
@lombok.RequiredArgsConstructor
public class ReadFileTool {
private final vip.mate.i18n.I18nService i18n;
private static final int DEFAULT_MAX_LINES = 1000;
private static final int MAX_OUTPUT_BYTES = 30 * 1024; // 30KB
/**
* 二进制文档扩展名集合 - 这些文件不应使用 read_file 读取
*/
private static final Set<String> DOCUMENT_EXTENSIONS = Set.of(
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".odt", ".ods", ".odp", ".rtf"
);
@Tool(description = """
Read the contents of a file. Supports line-range reading (1-based). \
Returns structured JSON with filePath, totalLines, readLines, content. \
Auto-truncates large files with continuation hints. \
Text files only; use extract_document_text for PDF/Office documents.""")
public String read_file(
@ToolParam(description = "Absolute or relative file path") String filePath,
@ToolParam(description = "Start line number (1-based, inclusive). Omit to start from line 1", required = false) Integer startLine,
@ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine,
// RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator.
@Nullable ToolContext ctx) {
JSONObject result = new JSONObject();
result.set("filePath", filePath);
try {
Path path;
try {
// RFC-063r §2.5: forward ToolContext so workspace boundary
// honors ChatOrigin.workspaceBasePath when available.
path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath, ctx);
} catch (IllegalArgumentException e) {
// Sandbox rejected the literal path. The LLM may have hallucinated
// a Linux-style path (e.g. /app/Dockerfile) for a chat-upload that
// actually lives under data/chat-uploads/{conversationId}/. Retry
// by basename before surfacing the boundary error.
Path attachment = ChatUploadResolver.resolve(filePath);
if (attachment == null) {
return errorResult(filePath, e.getMessage());
}
path = attachment;
}
// 文件存在性和类型校验
if (!Files.exists(path)) {
// The user-uploaded chat attachment is rendered to the LLM as
// "[附件] foo.txt" without its stored path, so LLMs often pass
// just the basename or a guessed absolute path. Fall back to
// looking up the basename inside the current conversation's
// chat-upload directory before reporting not-found.
Path attachment = ChatUploadResolver.resolve(filePath);
if (attachment == null) {
return errorResult(filePath, i18n.msg("tool.read_file.error.not_found", path));
}
log.info("[ReadFile] Resolved chat-upload attachment fallback: {} -> {}", filePath, attachment);
path = attachment;
}
if (Files.isDirectory(path)) {
return errorResult(filePath, i18n.msg("tool.read_file.error.is_directory", path));
}
if (!Files.isReadable(path)) {
return errorResult(filePath, i18n.msg("tool.read_file.error.not_readable", path));
}
// 检查是否是二进制文档 - 拒绝直接读取
String fileName = path.getFileName().toString().toLowerCase();
for (String ext : DOCUMENT_EXTENSIONS) {
if (fileName.endsWith(ext)) {
return errorResult(filePath, buildDocumentErrorMessage(fileName, ext));
}
}
// 读取所有行
List<String> allLines = readLinesUtf8(path);
int totalLines = allLines.size();
result.set("totalLines", totalLines);
// 解析行范围
int start = (startLine != null && startLine > 0) ? startLine : 1;
int end = (endLine != null && endLine > 0) ? endLine : totalLines;
// 范围校验
if (start > totalLines) {
return errorResult(filePath, i18n.msg("tool.read_file.error.start_exceeds", start, totalLines));
}
start = Math.max(1, start);
end = Math.min(end, totalLines);
if (start > end) {
return errorResult(filePath, i18n.msg("tool.read_file.error.start_gt_end", start, end));
}
// 提取指定范围的行(转为 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;
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) {
// 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;
}
sb.append(numberedLine);
linesRead++;
}
result.set("startLine", start);
result.set("endLine", start + linesRead - 1);
result.set("readLines", linesRead);
result.set("content", sb.toString());
if (truncated) {
result.set("truncated", true);
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);
}
log.info("[ReadFile] Read {} lines from {} (lines {}-{})", linesRead, path, start, start + linesRead - 1);
} catch (Exception e) {
log.error("[ReadFile] Failed to read file: {}", e.getMessage(), e);
return errorResult(filePath, i18n.msg("tool.read_file.error.read_exception", e.getMessage()));
}
return JSONUtil.toJsonPrettyStr(result);
}
/**
* 构建文档类型错误消息,引导用户使用正确的工具
*/
private String buildDocumentErrorMessage(String fileName, String ext) {
StringBuilder sb = new StringBuilder();
sb.append("无法直接读取二进制文档: ").append(fileName).append("\n\n");
sb.append("这是 ").append(ext.toUpperCase()).append(" 格式的 Office/PDF 文档,");
sb.append("不能作为纯文本读取。\n\n");
sb.append("请使用以下工具之一:\n");
switch (ext) {
case ".pdf" -> sb.append("- extract_pdf_text(filePath=\"").append(fileName).append("\")\n");
case ".docx", ".doc" -> sb.append("- extract_docx_text(filePath=\"").append(fileName).append("\")\n");
default -> sb.append("- extract_document_text(filePath=\"").append(fileName).append("\")\n");
}
sb.append("- extract_document_text(filePath=\"").append(fileName).append("\") - 通用文档提取\n");
sb.append("\n或者先检测文件类型\n");
sb.append("- detect_file_type(filePath=\"").append(fileName).append("\")");
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 文件做容错处理
*/
private List<String> readLinesUtf8(Path path) throws IOException {
try {
return Files.readAllLines(path, StandardCharsets.UTF_8);
} catch (java.nio.charset.MalformedInputException e) {
// 回退:以字节读取再忽略不合法字符
log.warn("[ReadFile] Non-UTF8 file, fallback with replacement: {}", path);
byte[] bytes = Files.readAllBytes(path);
String content = new String(bytes, StandardCharsets.UTF_8);
List<String> lines = new ArrayList<>();
for (String line : content.split("\n", -1)) {
lines.add(line);
}
return lines;
}
}
private String errorResult(String filePath, String message) {
JSONObject result = new JSONObject();
result.set("filePath", filePath);
result.set("error", true);
result.set("message", message);
return JSONUtil.toJsonPrettyStr(result);
}
}