← Back to task

Commit de9a342e

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]