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)