diff --git a/argi-orchestration/argi-agent-framework/src/main/java/io/github/agentic/ai/agent/orchestration/extension/file/LocalFilesystemBackend.java b/argi-orchestration/argi-agent-framework/src/main/java/io/github/agentic/ai/agent/orchestration/extension/file/LocalFilesystemBackend.java index 1c1c993e2..f04a1a28e 100644 --- a/argi-orchestration/argi-agent-framework/src/main/java/io/github/agentic/ai/agent/orchestration/extension/file/LocalFilesystemBackend.java +++ b/argi-orchestration/argi-agent-framework/src/main/java/io/github/agentic/ai/agent/orchestration/extension/file/LocalFilesystemBackend.java @@ -65,6 +65,7 @@ public class LocalFilesystemBackend implements FilesystemBackend { private static final String EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents"; private static final int MAX_LINE_LENGTH = 10000; private static final int LINE_NUMBER_WIDTH = 6; + private static final int MAX_RIPGREP_DIAGNOSTIC_LENGTH = 4096; private final Path cwd; private final boolean virtualMode; @@ -487,6 +488,13 @@ public Object grepRaw(String pattern, String path, String glob) { } catch (PatternSyntaxException e) { return "Invalid regex pattern: " + e.getMessage(); } + if (glob != null) { + try { + FileSystems.getDefault().getPathMatcher("glob:" + glob); + } catch (IllegalArgumentException e) { + return "Invalid glob pattern: " + e.getMessage(); + } + } // Resolve base path Path baseFull; @@ -504,10 +512,12 @@ public Object grepRaw(String pattern, String path, String glob) { } // Try ripgrep first - Map> results = ripgrepSearch(pattern, baseFull, glob); - if (results == null) { - results = javaSearch(pattern, baseFull, glob); + RipgrepSearchResult ripgrepResult = ripgrepSearch(pattern, baseFull, glob); + if (ripgrepResult.error() != null) { + return ripgrepResult.error(); } + Map> results = ripgrepResult.fallbackToJava() + ? javaSearch(pattern, baseFull, glob) : ripgrepResult.matches(); List matches = new ArrayList<>(); for (Map.Entry> entry : results.entrySet()) { @@ -520,7 +530,7 @@ public Object grepRaw(String pattern, String path, String glob) { // Helper methods - private Map> ripgrepSearch(String pattern, Path baseFull, String includeGlob) { + private RipgrepSearchResult ripgrepSearch(String pattern, Path baseFull, String includeGlob) { boolean explicitFile = Files.isRegularFile(baseFull, LinkOption.NOFOLLOW_LINKS); List cmd = new ArrayList<>(); cmd.add("rg"); @@ -543,62 +553,128 @@ private Map> ripgrepSearch(String pattern, Path baseFull try { if (explicitFile && Files.size(baseFull) > maxFileSizeBytes) { - return Collections.emptyMap(); + return RipgrepSearchResult.matches(Collections.emptyMap()); } ProcessBuilder pb = new ProcessBuilder(cmd); - Process process = pb.start(); - - Map> results = new HashMap<>(); - ObjectMapper mapper = new ObjectMapper(); + Process process; + try { + process = pb.start(); + } catch (IOException e) { + if (!ripgrepCommandExistsOnPath()) { + return RipgrepSearchResult.fallback(); + } + return RipgrepSearchResult.error("Unable to start ripgrep: " + e.getMessage()); + } - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { - try { - JsonNode data = mapper.readTree(line); - if (!"match".equals(data.path("type").asText())) { - continue; - } - JsonNode pdata = data.path("data"); - String ftext = pdata.path("path").path("text").asText(null); - if (ftext == null) { - continue; - } - Path p = Paths.get(ftext); - String virt; - if (virtualMode) { - try { - Path resolved = p.toAbsolutePath().normalize(); - Path relative = cwd.relativize(resolved); - virt = "/" + relative.toString().replace("\\", "/"); - } catch (Exception e) { + StringBuilder diagnostics = new StringBuilder(); + Thread diagnosticReader = new Thread(() -> readRipgrepDiagnostics(process, diagnostics), + "argi-ripgrep-diagnostics"); + diagnosticReader.setDaemon(true); + diagnosticReader.start(); + + try { + Map> results = new HashMap<>(); + ObjectMapper mapper = new ObjectMapper(); + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + try { + JsonNode data = mapper.readTree(line); + if (!"match".equals(data.path("type").asText())) { continue; } - } else { - virt = p.toString(); - } - Integer ln = pdata.path("line_number").asInt(0); - if (ln == 0) { - continue; - } - String lt = pdata.path("lines").path("text").asText(""); - if (lt.endsWith("\n")) { - lt = lt.substring(0, lt.length() - 1); + JsonNode pdata = data.path("data"); + String ftext = pdata.path("path").path("text").asText(null); + if (ftext == null) { + continue; + } + Path p = Paths.get(ftext); + String virt; + if (virtualMode) { + try { + Path resolved = p.toAbsolutePath().normalize(); + Path relative = cwd.relativize(resolved); + virt = "/" + relative.toString().replace("\\", "/"); + } catch (Exception e) { + continue; + } + } else { + virt = p.toString(); + } + Integer ln = pdata.path("line_number").asInt(0); + if (ln == 0) { + continue; + } + String lt = pdata.path("lines").path("text").asText(""); + if (lt.endsWith("\n")) { + lt = lt.substring(0, lt.length() - 1); + } + results.computeIfAbsent(virt, k -> new ArrayList<>()) + .add(new LineMatch(ln, lt)); + } catch (Exception ignored) { + // Skip malformed JSON lines } - results.computeIfAbsent(virt, k -> new ArrayList<>()) - .add(new LineMatch(ln, lt)); - } catch (Exception ignored) { - // Skip malformed JSON lines } } - } - process.waitFor(); - return results; + int exitCode = process.waitFor(); + diagnosticReader.join(); + if (exitCode == 0) { + return RipgrepSearchResult.matches(results); + } + if (exitCode == 1) { + return RipgrepSearchResult.matches(Collections.emptyMap()); + } + String diagnostic = diagnostics.toString().trim(); + if (diagnostic.contains("regex parse error:")) { + return RipgrepSearchResult.fallback(); + } + String message = "ripgrep failed with exit code " + exitCode; + return RipgrepSearchResult.error(diagnostic.isEmpty() ? message : message + ": " + diagnostic); + } catch (InterruptedException | IOException e) { + process.destroyForcibly(); + diagnosticReader.interrupt(); + throw e; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return RipgrepSearchResult.error("ripgrep search was interrupted"); } catch (Exception e) { - // Ripgrep not available or failed, return null to fallback to Java search - return null; + return RipgrepSearchResult.error("Unable to read ripgrep results: " + e.getMessage()); + } + } + + private boolean ripgrepCommandExistsOnPath() { + String path = System.getenv("PATH"); + if (path == null || path.isEmpty()) { + return false; + } + String pathSeparator = System.getProperty("path.separator"); + for (String directory : path.split(Pattern.quote(pathSeparator))) { + Path executable = directory.isEmpty() ? Paths.get("rg") : Paths.get(directory).resolve("rg"); + if (Files.isRegularFile(executable)) { + return true; + } + } + return false; + } + + private void readRipgrepDiagnostics(Process process, StringBuilder diagnostics) { + try (InputStreamReader reader = new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8)) { + char[] buffer = new char[512]; + int count; + while ((count = reader.read(buffer)) != -1) { + int remaining = MAX_RIPGREP_DIAGNOSTIC_LENGTH - diagnostics.length(); + if (remaining > 0) { + diagnostics.append(buffer, 0, Math.min(count, remaining)); + } + } + } catch (IOException e) { + if (diagnostics.length() < MAX_RIPGREP_DIAGNOSTIC_LENGTH) { + diagnostics.append("Unable to read ripgrep diagnostics: ").append(e.getMessage()); + } } } @@ -734,6 +810,22 @@ private String formatTimestamp(Instant instant) { .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); } + private record RipgrepSearchResult(Map> matches, boolean fallbackToJava, String error) { + + private static RipgrepSearchResult matches(Map> matches) { + return new RipgrepSearchResult(matches, false, null); + } + + private static RipgrepSearchResult fallback() { + return new RipgrepSearchResult(null, true, null); + } + + private static RipgrepSearchResult error(String error) { + return new RipgrepSearchResult(null, false, error); + } + + } + private static class LineMatch { final int lineNum; final String lineText; diff --git a/argi-orchestration/argi-agent-framework/src/test/java/io/github/agentic/ai/agent/orchestration/extension/file/LocalFilesystemBackendRegexSemanticsTest.java b/argi-orchestration/argi-agent-framework/src/test/java/io/github/agentic/ai/agent/orchestration/extension/file/LocalFilesystemBackendRegexSemanticsTest.java new file mode 100644 index 000000000..79a764889 --- /dev/null +++ b/argi-orchestration/argi-agent-framework/src/test/java/io/github/agentic/ai/agent/orchestration/extension/file/LocalFilesystemBackendRegexSemanticsTest.java @@ -0,0 +1,151 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.orchestration.extension.file; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +public class LocalFilesystemBackendRegexSemanticsTest { + + @TempDir + Path root; + + /** 验证启用 ripgrep 后,合法的 Java 前瞻正则与 Java 回退路径返回相同结果。 */ + @Test + void ripgrepAndJavaFallbackShouldMatchTheSameLookAheadPattern() throws Exception { + assumeTrue(isRipgrepAvailable(), "ripgrep is required to reproduce issue #230"); + Files.writeString(this.root.resolve("sample.txt"), "foobar\n", StandardCharsets.UTF_8); + + final String javaResult = probe("foo(?=bar)", "/sample.txt", null, emptyPath()); + final String ripgrepResult = probe("foo(?=bar)", "/sample.txt", null, null); + + assertEquals("matches=1", javaResult); + assertEquals(javaResult, ripgrepResult, + "Issue #230: enabling ripgrep must not change results for a valid Java regex"); + } + + /** 验证 ripgrep 的退出码 0 和 1 分别表示有匹配和无匹配。 */ + @Test + void ripgrepShouldPreserveMatchAndNoMatchExitSemantics() throws Exception { + assumeTrue(isRipgrepAvailable(), "ripgrep is required to verify its exit codes"); + Files.writeString(this.root.resolve("sample.txt"), "foobar\n", StandardCharsets.UTF_8); + + assertEquals("matches=1", probe("foo", "/sample.txt", null, null)); + assertEquals("matches=0", probe("missing", "/sample.txt", null, null)); + } + + /** 验证 ripgrep 执行失败时返回诊断,而不是伪装成无匹配。 */ + @Test + void ripgrepExecutionFailureShouldReturnDiagnostic() throws Exception { + Files.writeString(this.root.resolve("sample.txt"), "foobar\n", StandardCharsets.UTF_8); + final Path bin = Files.createDirectories(this.root.resolve("failing-rg")); + final Path executable = bin.resolve("rg"); + Files.writeString(executable, "#!/bin/sh\nprintf 'synthetic rg failure\\n' >&2\nexit 2\n", + StandardCharsets.UTF_8); + assertTrue(executable.toFile().setExecutable(true), "Could not make the fake rg executable"); + + final String result = probe("foo", "/sample.txt", null, bin); + + assertTrue(result.startsWith("error="), result); + assertTrue(result.contains("exit code 2"), result); + assertTrue(result.contains("synthetic rg failure"), result); + } + + /** 验证 PATH 中存在但没有执行权限的 rg 会返回启动错误。 */ + @Test + void nonExecutableRipgrepShouldReturnStartError() throws Exception { + Files.writeString(this.root.resolve("sample.txt"), "foobar\n", StandardCharsets.UTF_8); + final Path bin = Files.createDirectories(this.root.resolve("non-executable-rg")); + final Path executable = bin.resolve("rg"); + Files.writeString(executable, "#!/bin/sh\nexit 0\n", StandardCharsets.UTF_8); + assertTrue(executable.toFile().setExecutable(false), "Could not remove executable permission from fake rg"); + + final String result = probe("foo", "/sample.txt", null, bin); + + assertTrue(result.startsWith("error=Unable to start ripgrep:"), result); + } + + /** 验证无效 glob 返回诊断,不会被当作正常空结果。 */ + @Test + void invalidGlobShouldReturnDiagnostic() throws Exception { + Files.writeString(this.root.resolve("sample.txt"), "foobar\n", StandardCharsets.UTF_8); + + final String result = probe("foo", "/", "[", emptyPath()); + + assertTrue(result.startsWith("error="), result); + } + + private boolean isRipgrepAvailable() throws Exception { + final Process process = new ProcessBuilder("rg", "--version") + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + try { + return process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0; + } + finally { + process.destroyForcibly(); + } + } + + private Path emptyPath() throws Exception { + return Files.createDirectories(this.root.resolve("empty-path")); + } + + private String probe(String pattern, String searchPath, String glob, Path commandPath) throws Exception { + final String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + final String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + final String suffix = commandPath == null ? "ripgrep" : "custom-path"; + final Path result = this.root.resolve(suffix + ".result"); + final Path log = this.root.resolve(suffix + ".log"); + final ProcessBuilder builder = new ProcessBuilder(java, "-cp", classpath, + LocalFilesystemBackendRegexSemanticsTest.class.getName(), this.root.toString(), result.toString(), pattern, + searchPath, glob == null ? "" : glob); + if (commandPath != null) { + builder.environment().put("PATH", commandPath.toString()); + } + builder.redirectErrorStream(true).redirectOutput(log.toFile()); + final Process process = builder.start(); + try { + assertTrue(process.waitFor(30, TimeUnit.SECONDS), "Probe JVM timed out"); + final String output = Files.readString(log); + assertEquals(0, process.exitValue(), output); + return Files.readString(result).trim(); + } + finally { + process.destroyForcibly(); + } + } + + public static void main(String[] args) throws Exception { + final LocalFilesystemBackend backend = new LocalFilesystemBackend(args[0], true, 1); + final String glob = "".equals(args[4]) ? null : args[4]; + final Object result = backend.grepRaw(args[2], args[3], glob); + final String output = result instanceof java.util.List matches ? "matches=" + matches.size() + : "error=" + result; + Files.writeString(Path.of(args[1]), output, StandardCharsets.UTF_8); + } + +}