feat(tool): surface files written by execute_code as one-click downloads

This commit is contained in:
matevip 2026-06-30 11:31:14 +08:00
parent d390935763
commit 5cf1c46dd4
3 changed files with 220 additions and 6 deletions

View File

@ -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
);
}

View File

@ -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")

View File

@ -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());
}
}