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

105 lines
4.6 KiB
Java

package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
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 vip.mate.i18n.I18nService;
import vip.mate.tool.ConcurrencyUnsafe;
import vip.mate.tool.guard.WorkspacePathGuard;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
/** Append-only file mutation with retry idempotency and an optional tail precondition. */
@Slf4j
@Component
@RequiredArgsConstructor
public class AppendFileTool {
private final I18nService i18n;
@ConcurrencyUnsafe("append file — must serialize with reads/writes on overlapping paths")
@Tool(description = "Append a small content block to a file without rewriting existing content. "
+ "Creates the file and parent directories when absent. If the file already ends with the exact "
+ "content, the retry succeeds without writing it again. expectedTail can prevent appending to a "
+ "file that changed since it was read. Returns structured JSON.")
public String append_file(
@ToolParam(description = "Absolute or relative file path") String filePath,
@ToolParam(description = "Content block to append; send only the new content") String content,
@ToolParam(description = "Optional exact suffix that must currently end the file", required = false)
String expectedTail,
@Nullable ToolContext ctx) {
if (filePath == null || filePath.isBlank()) {
return error(filePath, "INVALID_ARGUMENT", i18n.msg("tool.write_file.error.path_empty"));
}
if (content == null || content.isEmpty()) {
return error(filePath, "INVALID_ARGUMENT", "content must not be empty");
}
try {
Path path = WorkspacePathGuard.validatePath(filePath, ctx);
if (Files.isDirectory(path)) {
return error(filePath, "IS_DIRECTORY", i18n.msg("tool.write_file.error.is_directory", path));
}
Path parent = path.getParent();
if (parent != null) Files.createDirectories(parent);
boolean existed = Files.exists(path);
String current = existed ? Files.readString(path, StandardCharsets.UTF_8) : "";
if (current.endsWith(content)) {
JSONObject result = baseResult(filePath);
result.set("bytesWritten", 0);
result.set("created", false);
result.set("alreadyApplied", true);
result.set("message", "Content already present at file tail; no write needed");
return JSONUtil.toJsonPrettyStr(result);
}
if (expectedTail != null && !current.endsWith(expectedTail)) {
return error(filePath, "PRECONDITION_FAILED",
"File tail changed; re-read the file before appending");
}
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
Files.write(path, bytes, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
JSONObject result = baseResult(filePath);
result.set("bytesWritten", bytes.length);
result.set("created", !existed);
result.set("alreadyApplied", false);
result.set("message", "Appended: " + path + " (" + bytes.length + " bytes)");
log.info("[AppendFile] Appended {} bytes to {}", bytes.length, path);
return JSONUtil.toJsonPrettyStr(result);
} catch (IllegalArgumentException e) {
return error(filePath, "PATH_REJECTED", e.getMessage());
} catch (Exception e) {
log.error("[AppendFile] Failed to append file: {}", e.getMessage(), e);
return error(filePath, "APPEND_FAILED",
i18n.msg("tool.write_file.error.write_exception", e.getMessage()));
}
}
private JSONObject baseResult(String filePath) {
JSONObject result = new JSONObject();
result.set("filePath", filePath);
return result;
}
private String error(String filePath, String code, String message) {
JSONObject result = baseResult(filePath);
result.set("error", true);
result.set("code", code);
result.set("message", message);
return JSONUtil.toJsonPrettyStr(result);
}
}