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

110 lines
5.1 KiB
Java

package vip.mate.tool.builtin;
import vip.mate.workspace.core.service.MemberFileIsolation;
import vip.mate.workspace.core.service.MemberFileAccess;
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 (!MemberFileIsolation.isEnabled() && parent != null) Files.createDirectories(parent);
boolean existed = Files.exists(path);
String current = existed ? (MemberFileIsolation.isEnabled()
? new String(MemberFileAccess.read(MemberFileIsolation.root(ctx), path, 32 * 1024 * 1024), StandardCharsets.UTF_8)
: 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);
if (MemberFileIsolation.isEnabled()) MemberFileAccess.write(MemberFileIsolation.root(ctx), path, bytes, true);
else 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);
}
}