diff --git a/src/main/java/dev/talos/runtime/toolcall/ToolSurfacePlanner.java b/src/main/java/dev/talos/runtime/toolcall/ToolSurfacePlanner.java index 3f7d8bb9..806a427e 100644 --- a/src/main/java/dev/talos/runtime/toolcall/ToolSurfacePlanner.java +++ b/src/main/java/dev/talos/runtime/toolcall/ToolSurfacePlanner.java @@ -58,6 +58,14 @@ && verifyOnlyDirectoryAwarePathCheck(contract)) { descriptor -> isFileReadTool(descriptor) || isDirectoryListingTool(descriptor), "verify-only path check with directory targets"); } + if (contract != null + && !contract.mutationAllowed() + && readOnlyPathExistenceCheck(contract)) { + return select( + registry, + descriptor -> isFileReadTool(descriptor) || isDirectoryListingTool(descriptor), + "read-only path existence surface"); + } if (contract != null && !contract.mutationAllowed() && !contract.expectedTargets().isEmpty()) { @@ -109,6 +117,10 @@ public static List defaultVisibleToolNames(TaskContract contract, Execut && verifyOnlyDirectoryAwarePathCheck(contract)) { return List.of("talos.list_dir", "talos.read_file"); } + if (!contract.mutationAllowed() + && readOnlyPathExistenceCheck(contract)) { + return List.of("talos.list_dir", "talos.read_file"); + } if (contract.mutationAllowed() && phase == ExecutionPhase.APPLY) { var workspaceOperation = WorkspaceOperationIntent.detect(contract); if (workspaceOperation.isPresent() && !requiresFileWriteForExactExpectation(contract)) { @@ -286,6 +298,23 @@ private static boolean verifyOnlyDirectoryAwarePathCheck(TaskContract contract) return mentionsDirectory && asksPathStatus; } + private static boolean readOnlyPathExistenceCheck(TaskContract contract) { + if (contract == null || contract.mutationAllowed() || contract.expectedTargets().isEmpty()) { + return false; + } + String request = contract.originalUserRequest(); + if (request == null || request.isBlank()) return false; + String lower = request.toLowerCase(Locale.ROOT); + boolean asksExistence = lower.contains("exists") + || lower.contains("exist") + || lower.contains("present") + || lower.contains("is there") + || lower.contains("are there"); + boolean asksPathStatus = lower.contains("path") + && (lower.contains("check") || lower.contains("verify") || lower.contains("whether")); + return asksExistence || asksPathStatus; + } + private static boolean containsExtensionlessSlashPath(String request) { if (request == null || request.isBlank()) return false; Matcher matcher = SLASH_PATH_CANDIDATE.matcher(request); diff --git a/src/test/java/dev/talos/runtime/toolcall/ToolSurfacePlannerTest.java b/src/test/java/dev/talos/runtime/toolcall/ToolSurfacePlannerTest.java index dc86d5cc..8ba425a5 100644 --- a/src/test/java/dev/talos/runtime/toolcall/ToolSurfacePlannerTest.java +++ b/src/test/java/dev/talos/runtime/toolcall/ToolSurfacePlannerTest.java @@ -284,6 +284,25 @@ void namedReadTargetSurfaceUsesFileTargetMetadataForProtectedAndPublicReads() { } } + @Test + void fileExistenceQuestionsExposeDirectoryAndFileReadEvidenceTools() { + var contract = TaskContractResolver.fromUserRequest( + "Check whether scripts.js exists and whether script.js exists. Do not change anything."); + + ToolSurfacePlanner.Plan plan = ToolSurfacePlanner.plan(contract, ExecutionPhase.INSPECT, registry()); + + List names = plan.nativeToolNames(); + assertEquals("read-only path existence surface", plan.reason()); + assertTrue(names.contains("talos.list_dir"), names.toString()); + assertTrue(names.contains("talos.read_file"), names.toString()); + assertFalse(names.contains("talos.write_file"), names.toString()); + assertFalse(names.contains("talos.edit_file"), names.toString()); + assertFalse(names.contains("talos.run_command"), names.toString()); + assertEquals( + List.of("talos.list_dir", "talos.read_file"), + ToolSurfacePlanner.defaultVisibleToolNames(contract, ExecutionPhase.INSPECT)); + } + @Test void verifyOnlyMixedFileAndDirectoryPathChecksExposeReadFileAndListDirOnly() { var contract = TaskContractResolver.fromUserRequest(