← Back to task

Commit b9f87740

commit b9f87740fa5fc1d8eea8c32524fd215b8631e938
Author: Ben Sima <ben@bensima.com>
Date:   Wed Dec 31 01:03:57 2025

    Implement skill tool for loading specialized instructions
    
    Add skillTool to Omni/Agent/Tools.hs:
    - Loads domain-specific instructions from Omni/Agent/Skills/*.md
    - Lists available skills when requested skill not found
    - Includes tests for schema, parsing, and loading
    
    Initial skills:
    - haskell-refactoring: Safe refactoring patterns
    - python-testing: Testing conventions
    
    Task-Id: t-245

diff --git a/Omni/Agent/Skills/haskell-refactoring.md b/Omni/Agent/Skills/haskell-refactoring.md
new file mode 100644
index 00000000..5750f595
--- /dev/null
+++ b/Omni/Agent/Skills/haskell-refactoring.md
@@ -0,0 +1,17 @@
+# Haskell Refactoring Skill
+
+## Safe Refactoring Patterns
+
+1. When splitting modules:
+   - Create the new module first with all needed imports
+   - Export the symbols from the new module
+   - Update the original to re-export from the new module
+   - Only after tests pass, remove the re-exports if desired
+
+2. When renaming functions:
+   - Use search_codebase to find ALL usages first
+   - Update the definition and all call sites atomically
+   
+3. Common import patterns in this codebase:
+   - `import Alpha` provides the custom prelude
+   - Use qualified imports for Data.Text, Data.Aeson, etc.
diff --git a/Omni/Agent/Skills/python-testing.md b/Omni/Agent/Skills/python-testing.md
new file mode 100644
index 00000000..c8e9ff5f
--- /dev/null
+++ b/Omni/Agent/Skills/python-testing.md
@@ -0,0 +1,7 @@
+# Python Testing Skill
+
+## Testing Patterns
+
+1. Tests are inline in the same file, in a `test()` function
+2. Use the pattern: `def test() -> int:` returning exit code
+3. Run tests with: `bild --test <namespace>`
diff --git a/Omni/Agent/Tools.hs b/Omni/Agent/Tools.hs
index 8c305a61..9335e388 100644
--- a/Omni/Agent/Tools.hs
+++ b/Omni/Agent/Tools.hs
@@ -30,6 +30,7 @@ module Omni.Agent.Tools
     searchAndReadTool,
     globTool,
     oracleTool,
+    skillTool,
     allTools,
     allToolsWithHistory,
     allToolsWithOracle,
@@ -43,6 +44,7 @@ module Omni.Agent.Tools
     SearchAndReadArgs (..),
     GlobArgs (..),
     OracleArgs (..),
+    SkillArgs (..),
     ToolResult (..),
     main,
     test,
@@ -65,6 +67,7 @@ import qualified Omni.Agent.Engine as Engine
 import qualified Omni.Test as Test
 import qualified System.Directory as Directory
 import qualified System.Exit as Exit
+import qualified System.FilePath as FilePath
 import qualified System.Process as Process
 
 -- | Edit history maps file paths to their previous content (before last edit)
@@ -102,8 +105,8 @@ test =
         case schema of
           Aeson.Object _ -> pure ()
           _ -> Test.assertFailure "Schema should be an object",
-      Test.unit "allTools contains 8 tools" <| do
-        length allTools Test.@=? 8,
+      Test.unit "allTools contains 9 tools" <| do
+        length allTools Test.@=? 9,
       Test.unit "ReadFileArgs parses correctly" <| do
         let json = Aeson.object ["path" .= ("test.txt" :: Text)]
         case Aeson.fromJSON json of
@@ -278,13 +281,41 @@ test =
             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 "allToolsWithOracle contains 10 tools" <| do
+        length (allToolsWithOracle "test-key") Test.@=? 10,
       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
+        ("It crashes on startup" `Text.isInfixOf` prompt) Test.@=? True,
+      Test.unit "skillTool schema is valid" <| do
+        let schema = Engine.toolJsonSchema skillTool
+        case schema of
+          Aeson.Object _ -> pure ()
+          _ -> Test.assertFailure "Schema should be an object",
+      Test.unit "SkillArgs parses correctly" <| do
+        let json = Aeson.object ["name" .= ("haskell-refactoring" :: Text)]
+        case Aeson.fromJSON json of
+          Aeson.Success (args :: SkillArgs) -> skillName args Test.@=? "haskell-refactoring"
+          Aeson.Error e -> Test.assertFailure e,
+      Test.unit "skillTool loads existing skill" <| do
+        let args = Aeson.object ["name" .= ("haskell-refactoring" :: Text)]
+        result <- Engine.toolExecute skillTool args
+        case Aeson.fromJSON result of
+          Aeson.Success (tr :: ToolResult) -> do
+            toolResultSuccess tr Test.@=? True
+            ("Haskell Refactoring" `Text.isInfixOf` toolResultOutput tr) Test.@=? True
+          Aeson.Error e -> Test.assertFailure e,
+      Test.unit "skillTool returns error for missing skill" <| do
+        let args = Aeson.object ["name" .= ("nonexistent-skill" :: Text)]
+        result <- Engine.toolExecute skillTool args
+        case Aeson.fromJSON result of
+          Aeson.Success (tr :: ToolResult) -> do
+            toolResultSuccess tr Test.@=? False
+            isJust (toolResultError tr) Test.@=? True
+            -- Should mention available skills
+            ("Available:" `Text.isInfixOf` fromMaybe "" (toolResultError tr)) Test.@=? True
+          Aeson.Error e -> Test.assertFailure e
     ]
 
 data ToolResult = ToolResult
@@ -347,7 +378,8 @@ allTools =
     runBashTool,
     searchCodebaseTool,
     searchAndReadTool,
-    globTool
+    globTool,
+    skillTool
   ]
 
 -- | Stub version of undo_edit that returns an error (for use without history tracking)
@@ -1246,3 +1278,80 @@ callOracleApi apiKey prompt = do
 allToolsWithOracle :: Text -> [Engine.Tool]
 allToolsWithOracle apiKey =
   allTools <> [oracleTool apiKey]
+
+-- Skill Tool Implementation
+
+newtype SkillArgs = SkillArgs
+  { skillName :: Text
+  }
+  deriving (Show, Eq, Generic)
+
+instance Aeson.FromJSON SkillArgs where
+  parseJSON =
+    Aeson.withObject "SkillArgs" <| \v ->
+      SkillArgs </ (v .: "name")
+
+-- | Directory containing skill files (relative to repo root)
+skillsDir :: FilePath
+skillsDir = "Omni/Agent/Skills"
+
+-- | Tool to load specialized instructions from skill files
+skillTool :: Engine.Tool
+skillTool =
+  Engine.Tool
+    { Engine.toolName = "skill",
+      Engine.toolDescription =
+        "Load specialized instructions for a domain. "
+          <> "Skills provide context-specific guidance for tasks like refactoring, testing, etc.",
+      Engine.toolJsonSchema =
+        Aeson.object
+          [ "type" .= ("object" :: Text),
+            "properties"
+              .= Aeson.object
+                [ "name"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Name of the skill to load (e.g., haskell-refactoring, python-testing)" :: Text)
+                      ]
+                ],
+            "required" .= (["name"] :: [Text])
+          ],
+      Engine.toolExecute = executeSkill
+    }
+
+executeSkill :: Aeson.Value -> IO Aeson.Value
+executeSkill v =
+  case Aeson.fromJSON v of
+    Aeson.Error e -> pure <| mkError (Text.pack e)
+    Aeson.Success args -> do
+      let name = skillName args
+          path = skillsDir FilePath.</> Text.unpack name <> ".md"
+      exists <- Directory.doesFileExist path
+      if exists
+        then do
+          content <- TextIO.readFile path
+          pure <| mkSuccess content
+        else do
+          -- List available skills
+          dirExists <- Directory.doesDirectoryExist skillsDir
+          if dirExists
+            then do
+              files <- Directory.listDirectory skillsDir
+              let skills =
+                    [ Text.pack (FilePath.dropExtension f)
+                      | f <- files,
+                        ".md" `List.isSuffixOf` f
+                    ]
+              pure
+                <| mkError
+                  ( "Skill not found: "
+                      <> name
+                      <> ". Available: "
+                      <> Text.intercalate ", " skills
+                  )
+            else
+              pure
+                <| mkError
+                  ( "Skills directory not found: "
+                      <> Text.pack skillsDir
+                  )