mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(tool): surface files written by execute_code as one-click downloads
This commit is contained in:
parent
d390935763
commit
5cf1c46dd4
@ -19,6 +19,7 @@ import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.runtime.SkillScriptExecutionService;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.skill.secret.SkillSecretService;
|
||||
import vip.mate.tool.document.GeneratedFileCache;
|
||||
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||
|
||||
import java.nio.file.Files;
|
||||
@ -28,6 +29,8 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Built-in tool: execute LLM-generated source code inline.
|
||||
@ -58,6 +61,13 @@ public class CodeExecuteTool {
|
||||
private final SkillScriptExecutionService executionService;
|
||||
private final SkillSecretService skillSecretService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final GeneratedFileCache generatedFileCache;
|
||||
|
||||
/** Artifact-surfacing bounds: keep heap and noise in check (best-effort feature). */
|
||||
private static final int ARTIFACT_SCAN_DEPTH = 4;
|
||||
private static final int MAX_ARTIFACTS = 8;
|
||||
private static final long MAX_ARTIFACT_BYTES = 20L * 1024 * 1024;
|
||||
private static final long MAX_TOTAL_ARTIFACT_BYTES = 48L * 1024 * 1024;
|
||||
|
||||
@Lazy
|
||||
@Autowired
|
||||
@ -79,7 +89,9 @@ public class CodeExecuteTool {
|
||||
a JSON array for multiple args, or plain text for a single argument.
|
||||
- timeoutSeconds: optional, default 30, max 300.
|
||||
|
||||
Returns: JSON with exitCode, stdout, stderr.
|
||||
Returns: JSON with exitCode, stdout, stderr, and (when the run wrote files)
|
||||
a generatedFiles array of [name](url) download links. When present, echo
|
||||
those links in your reply so the user can download the files you produced.
|
||||
|
||||
Security: dangerous operations trigger security approval. The server's own
|
||||
secret environment variables are not exposed to the code.
|
||||
@ -149,16 +161,104 @@ public class CodeExecuteTool {
|
||||
Long timeout = timeoutSeconds != null ? timeoutSeconds.longValue() : null;
|
||||
List<String> argList = normalizeArgs(args);
|
||||
|
||||
long runStart = System.currentTimeMillis();
|
||||
try {
|
||||
SkillScriptExecutionService.ScriptResult result =
|
||||
executionService.executeCode(language, code, workingDir, argList, envVars, timeout);
|
||||
return formatResult(result);
|
||||
// Surface any files the run wrote as one-click downloads so the user can
|
||||
// grab generated artifacts (xlsx / csv / images / …) without the model
|
||||
// having to call send_file or echo a server path.
|
||||
List<String> fileLinks = collectArtifactLinks(workingDir, runStart, ctx);
|
||||
return formatResult(result, fileLinks);
|
||||
} catch (Exception e) {
|
||||
log.error("[CodeExecute] Execution failed: {}", e.getMessage());
|
||||
return formatError("Execution failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: register files created or modified in {@code workingDir} during
|
||||
* this run into the generated-file cache and return their download links in the
|
||||
* {@code [name](url)} markdown form the chat layer scans for. Never throws — a
|
||||
* failure here must not fail the code run.
|
||||
*/
|
||||
List<String> collectArtifactLinks(Path workingDir, long sinceMillis, @Nullable ToolContext ctx) {
|
||||
if (workingDir == null || !Files.isDirectory(workingDir)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> links = new ArrayList<>();
|
||||
long totalBytes = 0L;
|
||||
try (Stream<Path> walk = Files.walk(workingDir, ARTIFACT_SCAN_DEPTH)) {
|
||||
List<Path> candidates = walk
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(p -> !isNoiseArtifact(p))
|
||||
.filter(p -> modifiedSince(p, sinceMillis))
|
||||
.limit(200)
|
||||
.toList();
|
||||
for (Path p : candidates) {
|
||||
if (links.size() >= MAX_ARTIFACTS) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
long size = Files.size(p);
|
||||
if (size <= 0 || size > MAX_ARTIFACT_BYTES || totalBytes + size > MAX_TOTAL_ARTIFACT_BYTES) {
|
||||
continue;
|
||||
}
|
||||
byte[] bytes = Files.readAllBytes(p);
|
||||
totalBytes += size;
|
||||
String name = p.getFileName().toString();
|
||||
String id = generatedFileCache.put(bytes, name, probeMime(p, name));
|
||||
links.add("[" + name + "](" + generatedFileCache.downloadUrl(id, ctx) + ")");
|
||||
} catch (Exception perFile) {
|
||||
log.debug("[CodeExecute] skip artifact {}: {}", p, perFile.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[CodeExecute] artifact scan failed for {}: {}", workingDir, e.getMessage());
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
private static boolean modifiedSince(Path p, long sinceMillis) {
|
||||
try {
|
||||
// 1s slack absorbs filesystem mtime granularity.
|
||||
return Files.getLastModifiedTime(p).toMillis() >= sinceMillis - 1000L;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Skip hidden files, dependency/cache dirs, and obvious scratch/log files. */
|
||||
private static boolean isNoiseArtifact(Path p) {
|
||||
for (Path seg : p) {
|
||||
String s = seg.toString();
|
||||
if (s.startsWith(".") || s.equals("__pycache__") || s.equals("node_modules")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String name = p.getFileName().toString().toLowerCase();
|
||||
return name.endsWith(".pyc") || name.endsWith(".tmp") || name.endsWith(".lock")
|
||||
|| name.endsWith(".log") || name.endsWith(".class");
|
||||
}
|
||||
|
||||
private static String probeMime(Path p, String name) {
|
||||
try {
|
||||
String mime = Files.probeContentType(p);
|
||||
if (mime != null && !mime.isBlank()) {
|
||||
return mime;
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
// fall through to extension default
|
||||
}
|
||||
String lower = name.toLowerCase();
|
||||
if (lower.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
if (lower.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
if (lower.endsWith(".csv")) return "text/csv";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the JSON-encoded {@code args} string into a positional argument list,
|
||||
* mirroring {@code SkillScriptTool.normalizeArgs}: a JSON array becomes one
|
||||
@ -193,12 +293,19 @@ public class CodeExecuteTool {
|
||||
return List.of(trimmed);
|
||||
}
|
||||
|
||||
private String formatResult(SkillScriptExecutionService.ScriptResult result) {
|
||||
private String formatResult(SkillScriptExecutionService.ScriptResult result, List<String> fileLinks) {
|
||||
// generatedFiles carries [name](url) markdown so the chat layer surfaces the
|
||||
// artifacts as one-click downloads, and the model can echo them to the user.
|
||||
String filesField = (fileLinks == null || fileLinks.isEmpty()) ? "" :
|
||||
",\n \"generatedFiles\": ["
|
||||
+ fileLinks.stream().map(this::jsonEscape).collect(Collectors.joining(", "))
|
||||
+ "]";
|
||||
return String.format(
|
||||
"{\n \"exitCode\": %d,\n \"stdout\": %s,\n \"stderr\": %s\n}",
|
||||
"{\n \"exitCode\": %d,\n \"stdout\": %s,\n \"stderr\": %s%s\n}",
|
||||
result.getExitCode(),
|
||||
jsonEscape(result.getStdout()),
|
||||
jsonEscape(result.getStderr())
|
||||
jsonEscape(result.getStderr()),
|
||||
filesField
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ class CodeExecuteToolArgsTest {
|
||||
|
||||
/** Unused collaborators are null — {@code normalizeArgs} only needs the mapper. */
|
||||
private final CodeExecuteTool tool =
|
||||
new CodeExecuteTool(null, null, null, objectMapper);
|
||||
new CodeExecuteTool(null, null, null, objectMapper, null);
|
||||
|
||||
@Test
|
||||
@DisplayName("null / blank / empty-array args yield no argument list")
|
||||
|
||||
@ -0,0 +1,107 @@
|
||||
package vip.mate.tool.builtin;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.tool.document.GeneratedFileCache;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* After a code run, files it wrote to the working dir should surface as
|
||||
* one-click downloads (issue #191): the tool registers them in the
|
||||
* generated-file cache and returns {@code [name](url)} links the chat layer
|
||||
* already scans for. Pre-existing files, scratch/noise files, and oversized
|
||||
* files must be left out.
|
||||
*/
|
||||
class CodeExecuteToolArtifactTest {
|
||||
|
||||
// Mirror of ChatController.GENERATED_FILE_LINK_PATTERN so we assert the links
|
||||
// are in the exact shape the chat layer extracts.
|
||||
private static final Pattern LINK = Pattern.compile(
|
||||
"\\[([^\\]]+)\\]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)");
|
||||
|
||||
private Path tmp;
|
||||
private Path cacheDir;
|
||||
|
||||
private CodeExecuteTool newTool(GeneratedFileCache cache) {
|
||||
// Only generatedFileCache is exercised here; the other collaborators are
|
||||
// unused by collectArtifactLinks.
|
||||
return new CodeExecuteTool(null, null, null, null, cache);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() throws Exception {
|
||||
for (Path root : new Path[]{tmp, cacheDir}) {
|
||||
if (root != null && Files.exists(root)) {
|
||||
try (var s = Files.walk(root)) {
|
||||
s.sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||
try { Files.deleteIfExists(p); } catch (Exception ignore) { }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Files written during the run surface as download links; old/noise files do not")
|
||||
void surfacesNewlyWrittenFiles() throws Exception {
|
||||
tmp = Files.createTempDirectory("codeexec-artifacts-");
|
||||
cacheDir = Files.createTempDirectory("codeexec-cache-");
|
||||
GeneratedFileCache cache = new GeneratedFileCache(cacheDir);
|
||||
|
||||
// Pre-existing file, modified well before the run window — must be ignored.
|
||||
Path old = Files.write(tmp.resolve("old.txt"), "old".getBytes());
|
||||
Files.setLastModifiedTime(old, java.nio.file.attribute.FileTime.fromMillis(1_000L));
|
||||
|
||||
long runStart = System.currentTimeMillis();
|
||||
Thread.sleep(5);
|
||||
|
||||
// Files produced "by the run".
|
||||
byte[] xlsx = "PK fake-xlsx-bytes".getBytes();
|
||||
Files.write(tmp.resolve("report.xlsx"), xlsx);
|
||||
Files.write(tmp.resolve("data.csv"), "a,b\n1,2\n".getBytes());
|
||||
// Noise that must be filtered out.
|
||||
Files.write(tmp.resolve("scratch.tmp"), "x".getBytes());
|
||||
Files.write(tmp.resolve(".hidden"), "x".getBytes());
|
||||
|
||||
List<String> links = newTool(cache).collectArtifactLinks(tmp, runStart, null);
|
||||
|
||||
// report.xlsx + data.csv only.
|
||||
assertEquals(2, links.size(), "expected the two real artifacts, got: " + links);
|
||||
assertTrue(links.stream().anyMatch(l -> l.contains("report.xlsx")));
|
||||
assertTrue(links.stream().anyMatch(l -> l.contains("data.csv")));
|
||||
assertFalse(links.stream().anyMatch(l -> l.contains("old.txt")), "pre-existing file must not surface");
|
||||
assertFalse(links.stream().anyMatch(l -> l.contains("scratch.tmp")), ".tmp must not surface");
|
||||
assertFalse(links.stream().anyMatch(l -> l.contains(".hidden")), "hidden file must not surface");
|
||||
|
||||
// Each link is in the exact shape the chat layer extracts, and the bytes
|
||||
// round-trip through the cache (so the download endpoint can serve them).
|
||||
for (String link : links) {
|
||||
Matcher m = LINK.matcher(link);
|
||||
assertTrue(m.matches(), "link not in extractable form: " + link);
|
||||
String id = m.group(2).substring(m.group(2).lastIndexOf('/') + 1);
|
||||
Optional<GeneratedFileCache.Entry> entry = cache.get(id);
|
||||
assertTrue(entry.isPresent(), "cached artifact must be retrievable: " + link);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null / non-existent working dir yields no links and does not throw")
|
||||
void nullWorkingDirIsSafe() throws Exception {
|
||||
cacheDir = Files.createTempDirectory("codeexec-cache-");
|
||||
CodeExecuteTool tool = newTool(new GeneratedFileCache(cacheDir));
|
||||
assertTrue(tool.collectArtifactLinks(null, 0L, null).isEmpty());
|
||||
assertTrue(tool.collectArtifactLinks(Path.of("/no/such/dir/xyz"), 0L, null).isEmpty());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user