← Back to task

Commit a0193dd7

commit a0193dd7aad0fcc94a022685ed2c5ca024a9ce4c
Author: Ben Sima <ben@bensima.com>
Date:   Wed Dec 31 00:11:48 2025

    Omni/Agent/Tools.hs: Implement glob tool for file pattern matching
    
    Automated via pi-review.
    
    Task-Id: t-241

diff --git a/Omni/Agent/Tools.hs b/Omni/Agent/Tools.hs
index 5078b117..ae822443 100644
--- a/Omni/Agent/Tools.hs
+++ b/Omni/Agent/Tools.hs
@@ -23,6 +23,7 @@ module Omni.Agent.Tools
     runBashTool,
     searchCodebaseTool,
     searchAndReadTool,
+    globTool,
     allTools,
     ReadFileArgs (..),
     WriteFileArgs (..),
@@ -30,6 +31,7 @@ module Omni.Agent.Tools
     RunBashArgs (..),
     SearchCodebaseArgs (..),
     SearchAndReadArgs (..),
+    GlobArgs (..),
     ToolResult (..),
     main,
     test,
@@ -80,8 +82,8 @@ test =
         case schema of
           Aeson.Object _ -> pure ()
           _ -> Test.assertFailure "Schema should be an object",
-      Test.unit "allTools contains 6 tools" <| do
-        length allTools Test.@=? 6,
+      Test.unit "allTools contains 7 tools" <| do
+        length allTools Test.@=? 7,
       Test.unit "ReadFileArgs parses correctly" <| do
         let json = Aeson.object ["path" .= ("test.txt" :: Text)]
         case Aeson.fromJSON json of
@@ -184,6 +186,30 @@ test =
         result <- Engine.toolExecute searchCodebaseTool args
         case Aeson.fromJSON result of
           Aeson.Success (tr :: ToolResult) -> toolResultSuccess tr Test.@=? True
+          Aeson.Error e -> Test.assertFailure e,
+      Test.unit "globTool schema is valid" <| do
+        let schema = Engine.toolJsonSchema globTool
+        case schema of
+          Aeson.Object _ -> pure ()
+          _ -> Test.assertFailure "Schema should be an object",
+      Test.unit "GlobArgs parses correctly" <| do
+        let json = Aeson.object ["pattern" .= ("*.hs" :: Text)]
+        case Aeson.fromJSON json of
+          Aeson.Success (args :: GlobArgs) -> globPattern args Test.@=? "*.hs"
+          Aeson.Error e -> Test.assertFailure e,
+      Test.unit "globTool returns results for *.hs pattern" <| do
+        let args =
+              Aeson.object
+                [ "pattern" .= ("*.hs" :: Text),
+                  "path" .= ("Omni/Agent" :: Text),
+                  "limit" .= (10 :: Int)
+                ]
+        result <- Engine.toolExecute globTool args
+        case Aeson.fromJSON result of
+          Aeson.Success (tr :: ToolResult) -> do
+            toolResultSuccess tr Test.@=? True
+            -- Should find at least Tools.hs
+            not (Text.null (toolResultOutput tr)) Test.@=? True
           Aeson.Error e -> Test.assertFailure e
     ]
 
@@ -245,7 +271,8 @@ allTools =
     editFileTool,
     runBashTool,
     searchCodebaseTool,
-    searchAndReadTool
+    searchAndReadTool,
+    globTool
   ]
 
 data ReadFileArgs = ReadFileArgs
@@ -721,3 +748,85 @@ executeSearchAndRead v =
           pure <| mkSuccess "No matches found"
         Exit.ExitFailure code ->
           pure <| mkError ("ripgrep failed: " <> tshow code <> ": " <> Text.pack stderrStr)
+
+data GlobArgs = GlobArgs
+  { globPattern :: Text,
+    globPath :: Maybe Text,
+    globLimit :: Maybe Int
+  }
+  deriving (Show, Eq, Generic)
+
+instance Aeson.FromJSON GlobArgs where
+  parseJSON =
+    Aeson.withObject "GlobArgs" <| \v ->
+      (GlobArgs </ (v .: "pattern"))
+        <*> (v .:? "path")
+        <*> (v .:? "limit")
+
+globTool :: Engine.Tool
+globTool =
+  Engine.Tool
+    { Engine.toolName = "glob",
+      Engine.toolDescription = "Find files matching a glob pattern. Respects .gitignore.",
+      Engine.toolJsonSchema =
+        Aeson.object
+          [ "type" .= ("object" :: Text),
+            "properties"
+              .= Aeson.object
+                [ "pattern"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Glob pattern like **/*.hs or src/**/*.py" :: Text)
+                      ],
+                  "path"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Directory to search in (default: current directory)" :: Text)
+                      ],
+                  "limit"
+                    .= Aeson.object
+                      [ "type" .= ("integer" :: Text),
+                        "description" .= ("Maximum number of results (default: 100)" :: Text)
+                      ]
+                ],
+            "required" .= (["pattern"] :: [Text])
+          ],
+      Engine.toolExecute = executeGlob
+    }
+
+executeGlob :: Aeson.Value -> IO Aeson.Value
+executeGlob v =
+  case Aeson.fromJSON v of
+    Aeson.Error e -> pure <| mkError (Text.pack e)
+    Aeson.Success args -> do
+      let pat = Text.unpack (globPattern args)
+          searchPath = maybe "." Text.unpack (globPath args)
+          limit = fromMaybe 100 (globLimit args)
+          -- fd --glob "<pattern>" --type f "<path>"
+          fdArgs = ["--glob", pat, "--type", "f", searchPath]
+          proc = Process.proc "fd" fdArgs
+      (exitCode, stdoutStr, _) <- Process.readCreateProcessWithExitCode proc ""
+      case exitCode of
+        Exit.ExitSuccess -> do
+          let allResults = Text.lines (Text.pack stdoutStr)
+              limitedResults = take limit allResults
+              output = Text.unlines limitedResults
+          pure <| mkSuccess output
+        Exit.ExitFailure _ ->
+          -- Fall back to find if fd fails
+          executeFindFallback pat searchPath limit
+
+-- | Fallback to find command when fd is not available
+executeFindFallback :: String -> String -> Int -> IO Aeson.Value
+executeFindFallback pat searchPath limit = do
+  let findArgs = [searchPath, "-name", pat, "-type", "f"]
+      proc = Process.proc "find" findArgs
+  (exitCode, stdoutStr, _) <- Process.readCreateProcessWithExitCode proc ""
+  case exitCode of
+    Exit.ExitSuccess -> do
+      let allResults = Text.lines (Text.pack stdoutStr)
+          limitedResults = take limit allResults
+          output = Text.unlines limitedResults
+      pure <| mkSuccess output
+    Exit.ExitFailure code ->
+      pure <| mkError ("find failed with code " <> tshow code)