commit de9a342ed3af55f453c0c62d8ca25c58c5cc86f5
Author: Ben Sima <ben@bensima.com>
Date: Wed Dec 31 00:46:52 2025
Omni/Agent/Tools.hs: Implement oracle tool for complex reasoning
Automated via pi-review.
Task-Id: t-243
diff --git a/Omni/Agent/Tools.hs b/Omni/Agent/Tools.hs
index ea765968..8c305a61 100644
--- a/Omni/Agent/Tools.hs
+++ b/Omni/Agent/Tools.hs
@@ -10,12 +10,15 @@
-- - editFile: Search/replace edit
-- - runBash: Execute shell commands
-- - searchCodebase: Ripgrep wrapper for code search
+-- - oracle: Consult a reasoning model for complex decisions
--
-- All tools return structured JSON results.
--
-- : out omni-agent-tools
-- : dep aeson
-- : dep directory
+-- : dep http-conduit
+-- : dep case-insensitive
module Omni.Agent.Tools
( readFileTool,
writeFileTool,
@@ -26,8 +29,10 @@ module Omni.Agent.Tools
searchCodebaseTool,
searchAndReadTool,
globTool,
+ oracleTool,
allTools,
allToolsWithHistory,
+ allToolsWithOracle,
EditHistory,
ReadFileArgs (..),
WriteFileArgs (..),
@@ -37,6 +42,7 @@ module Omni.Agent.Tools
SearchCodebaseArgs (..),
SearchAndReadArgs (..),
GlobArgs (..),
+ OracleArgs (..),
ToolResult (..),
main,
test,
@@ -46,11 +52,15 @@ where
import Alpha
import Data.Aeson ((.!=), (.:), (.:?), (.=))
import qualified Data.Aeson as Aeson
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.CaseInsensitive as CI
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import qualified Data.List as List
import qualified Data.Map.Strict as Map
import qualified Data.Text as Text
+import qualified Data.Text.Encoding as TE
import qualified Data.Text.IO as TextIO
+import qualified Network.HTTP.Simple as HTTP
import qualified Omni.Agent.Engine as Engine
import qualified Omni.Test as Test
import qualified System.Directory as Directory
@@ -244,7 +254,37 @@ test =
Aeson.Error e -> Test.assertFailure e,
Test.unit "allToolsWithHistory contains 8 tools" <| do
historyRef <- Data.IORef.newIORef Map.empty
- length (allToolsWithHistory historyRef) Test.@=? 8
+ length (allToolsWithHistory historyRef) Test.@=? 8,
+ Test.unit "oracleTool schema is valid" <| do
+ let schema = Engine.toolJsonSchema (oracleTool "test-key")
+ case schema of
+ Aeson.Object _ -> pure ()
+ _ -> Test.assertFailure "Schema should be an object",
+ Test.unit "OracleArgs parses correctly" <| do
+ let json = Aeson.object ["task" .= ("How should I refactor this?" :: Text)]
+ case Aeson.fromJSON json of
+ Aeson.Success (args :: OracleArgs) -> oracleTask args Test.@=? "How should I refactor this?"
+ Aeson.Error e -> Test.assertFailure e,
+ Test.unit "OracleArgs parses with all fields" <| do
+ let json =
+ Aeson.object
+ [ "task" .= ("Debug this issue" :: Text),
+ "context" .= ("The build is failing" :: Text),
+ "files" .= (["/tmp/foo.hs", "/tmp/bar.hs"] :: [Text])
+ ]
+ case Aeson.fromJSON json of
+ Aeson.Success (args :: OracleArgs) -> do
+ oracleTask args Test.@=? "Debug this issue"
+ oracleContext args Test.@=? Just "The build is failing"
+ oracleFiles args Test.@=? Just ["/tmp/foo.hs", "/tmp/bar.hs"]
+ Aeson.Error e -> Test.assertFailure e,
+ Test.unit "allToolsWithOracle contains 9 tools" <| do
+ length (allToolsWithOracle "test-key") Test.@=? 9,
+ Test.unit "buildOraclePrompt formats correctly" <| do
+ let args = OracleArgs "Fix this bug" (Just "It crashes on startup") Nothing
+ prompt = buildOraclePrompt args []
+ ("Fix this bug" `Text.isInfixOf` prompt) Test.@=? True
+ ("It crashes on startup" `Text.isInfixOf` prompt) Test.@=? True
]
data ToolResult = ToolResult
@@ -1017,3 +1057,192 @@ executeFindFallback pat searchPath limit = do
pure <| mkSuccess output
Exit.ExitFailure code ->
pure <| mkError ("find failed with code " <> tshow code)
+
+-- Oracle Tool Implementation
+
+data OracleArgs = OracleArgs
+ { oracleTask :: Text,
+ oracleContext :: Maybe Text,
+ oracleFiles :: Maybe [Text]
+ }
+ deriving (Show, Eq, Generic)
+
+instance Aeson.FromJSON OracleArgs where
+ parseJSON =
+ Aeson.withObject "OracleArgs" <| \v ->
+ (OracleArgs </ (v .: "task"))
+ <*> (v .:? "context")
+ <*> (v .:? "files")
+
+-- | Oracle tool for consulting a reasoning model on complex problems.
+-- Takes an API key (from OPENROUTER_API_KEY) to make API calls.
+oracleTool :: Text -> Engine.Tool
+oracleTool apiKey =
+ Engine.Tool
+ { Engine.toolName = "oracle",
+ Engine.toolDescription =
+ "Consult a reasoning model for complex planning, debugging, or architectural decisions. "
+ <> "Use this when you need to think through a difficult problem, plan a multi-file refactor, "
+ <> "or debug a tricky issue. The oracle only advises - it does not take action.",
+ Engine.toolJsonSchema =
+ Aeson.object
+ [ "type" .= ("object" :: Text),
+ "properties"
+ .= Aeson.object
+ [ "task"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("The problem or question to analyze" :: Text)
+ ],
+ "context"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Optional context about current situation" :: Text)
+ ],
+ "files"
+ .= Aeson.object
+ [ "type" .= ("array" :: Text),
+ "items" .= Aeson.object ["type" .= ("string" :: Text)],
+ "description" .= ("Optional file paths to include in the analysis" :: Text)
+ ]
+ ],
+ "required" .= (["task"] :: [Text])
+ ],
+ Engine.toolExecute = executeOracle apiKey
+ }
+
+executeOracle :: Text -> Aeson.Value -> IO Aeson.Value
+executeOracle apiKey v =
+ case Aeson.fromJSON v of
+ Aeson.Error e -> pure <| mkError (Text.pack e)
+ Aeson.Success args -> do
+ -- Read files if provided
+ fileContents <- case oracleFiles args of
+ Nothing -> pure []
+ Just paths -> traverse readFileContent paths
+
+ -- Build the prompt
+ let prompt = buildOraclePrompt args fileContents
+
+ -- Call OpenRouter API
+ result <- callOracleApi apiKey prompt
+ case result of
+ Left err -> pure <| mkError err
+ Right response -> pure <| mkSuccess response
+
+-- | Read a file's content for the oracle, handling errors gracefully
+readFileContent :: Text -> IO (Text, Either Text Text)
+readFileContent path = do
+ let pathStr = Text.unpack path
+ exists <- Directory.doesFileExist pathStr
+ if exists
+ then do
+ content <- TextIO.readFile pathStr
+ -- Truncate very large files
+ let truncated =
+ if Text.length content > 10000
+ then Text.take 10000 content <> "\n[... truncated ...]"
+ else content
+ pure (path, Right truncated)
+ else pure (path, Left "File not found")
+
+-- | Build the oracle prompt from arguments and file contents
+buildOraclePrompt :: OracleArgs -> [(Text, Either Text Text)] -> Text
+buildOraclePrompt args fileContents =
+ "You are a senior engineering advisor. Analyze the following problem and provide specific, actionable guidance.\n\n"
+ <> "## Problem\n"
+ <> oracleTask args
+ <> "\n\n"
+ <> contextSection
+ <> filesSection
+ <> "\nProvide your analysis and recommendations. Be specific about what to do and in what order.\n"
+ <> "Do not implement - only advise."
+ where
+ contextSection = case oracleContext args of
+ Nothing -> ""
+ Just ctx -> "## Context\n" <> ctx <> "\n\n"
+
+ filesSection
+ | null fileContents = ""
+ | otherwise =
+ "## Relevant Files\n"
+ <> Text.concat (map formatFile fileContents)
+ <> "\n"
+
+ formatFile (path, Right content) =
+ "### " <> path <> "\n```\n" <> content <> "\n```\n\n"
+ formatFile (path, Left err) =
+ "### " <> path <> "\nError reading file: " <> err <> "\n\n"
+
+-- | Oracle API response structure for parsing
+newtype OracleApiResponse = OracleApiResponse
+ { oracleApiChoices :: [OracleChoice]
+ }
+ deriving (Generic)
+
+instance Aeson.FromJSON OracleApiResponse where
+ parseJSON =
+ Aeson.withObject "OracleApiResponse" <| \v ->
+ OracleApiResponse </ (v .: "choices")
+
+newtype OracleChoice = OracleChoice
+ { oracleChoiceMessage :: OracleMessage
+ }
+ deriving (Generic)
+
+instance Aeson.FromJSON OracleChoice where
+ parseJSON =
+ Aeson.withObject "OracleChoice" <| \v ->
+ OracleChoice </ (v .: "message")
+
+newtype OracleMessage = OracleMessage
+ { oracleMessageContent :: Text
+ }
+ deriving (Generic)
+
+instance Aeson.FromJSON OracleMessage where
+ parseJSON =
+ Aeson.withObject "OracleMessage" <| \v ->
+ OracleMessage </ (v .: "content")
+
+-- | Call OpenRouter API with the oracle prompt
+callOracleApi :: Text -> Text -> IO (Either Text Text)
+callOracleApi apiKey prompt = do
+ let url = "https://openrouter.ai/api/v1/chat/completions"
+ req0 <- HTTP.parseRequest url
+ let body =
+ Aeson.object
+ [ "model" .= ("anthropic/claude-sonnet-4" :: Text),
+ "messages"
+ .= [ Aeson.object
+ [ "role" .= ("user" :: Text),
+ "content" .= prompt
+ ]
+ ],
+ "max_tokens" .= (4000 :: Int)
+ ]
+ req =
+ HTTP.setRequestMethod "POST"
+ <| HTTP.setRequestHeader "Content-Type" ["application/json"]
+ <| HTTP.setRequestHeader "Authorization" ["Bearer " <> TE.encodeUtf8 apiKey]
+ <| HTTP.addRequestHeader (CI.mk "HTTP-Referer") "https://omni.dev"
+ <| HTTP.addRequestHeader (CI.mk "X-Title") "Omni Agent Oracle"
+ <| HTTP.setRequestBodyLBS (Aeson.encode body)
+ <| req0
+
+ response <- HTTP.httpLBS req
+ let status = HTTP.getResponseStatusCode response
+ respBody = HTTP.getResponseBody response
+ if status >= 200 && status < 300
+ then case Aeson.eitherDecode respBody of
+ Right (resp :: OracleApiResponse) ->
+ case oracleApiChoices resp of
+ (c : _) -> pure (Right (oracleMessageContent (oracleChoiceMessage c)))
+ [] -> pure (Left "No choices in oracle response")
+ Left err -> pure (Left ("Failed to parse oracle response: " <> Text.pack err))
+ else pure (Left ("OpenRouter API error: " <> tshow status <> " - " <> TE.decodeUtf8 (BL.toStrict respBody)))
+
+-- | All tools plus the oracle tool (requires API key)
+allToolsWithOracle :: Text -> [Engine.Tool]
+allToolsWithOracle apiKey =
+ allTools <> [oracleTool apiKey]