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
+ )