commit 4a4a4788ac919417310ee06c69c718e71048ffb0
Author: Ben Sima <ben@bensima.com>
Date: Fri Jan 2 06:51:23 2026
Add Omni/Ide/Coder.hs - Native Haskell coder agent
Replaces pi-code.sh with a Haskell module using the Agent API:
- runCoder :: CoderConfig -> IO CoderResult
- Claims task, builds prompt with context
- Runs agent with tools
- Verifies compilation for buildable files
Task-Id: t-278.2
diff --git a/Omni/Ide/Coder.hs b/Omni/Ide/Coder.hs
new file mode 100644
index 00000000..aba764a4
--- /dev/null
+++ b/Omni/Ide/Coder.hs
@@ -0,0 +1,336 @@
+#!/usr/bin/env run.sh
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Coder agent - runs coding tasks using the Agent API.
+--
+-- Replaces pi-code.sh with a native Haskell implementation.
+--
+-- Usage:
+-- runCoder taskId maybeExtraInstructions
+--
+-- What it does:
+-- 1. Claims the task (sets status to in-progress)
+-- 2. Builds prompt with task context (title, description, namespace, feedback)
+-- 3. Runs agent to make code changes
+-- 4. Verifies compilation if namespace is a buildable file
+--
+-- : out coder
+-- : dep aeson
+-- : dep process
+module Omni.Ide.Coder
+ ( runCoder,
+ CoderConfig (..),
+ CoderResult (..),
+ defaultCoderConfig,
+ main,
+ test,
+ )
+where
+
+import Alpha
+import qualified Data.Text as Text
+import qualified Data.Text.IO as TextIO
+import qualified Omni.Agent.Auth as Auth
+import qualified Omni.Agent.Engine as Engine
+import qualified Omni.Agent.Provider as Provider
+import qualified Omni.Agent.Tools as Tools
+import qualified Omni.Agent.Tools.WebSearch as WebSearch
+import qualified Omni.Task.Core as Task
+import qualified Omni.Test as Test
+import qualified System.Directory as Directory
+import qualified System.Environment as Environment
+import qualified System.Exit as Exit
+import qualified System.IO as IO
+import qualified System.Process as Process
+
+main :: IO ()
+main = Test.run test
+
+test :: Test.Tree
+test =
+ Test.group
+ "Omni.Ide.Coder"
+ [ Test.unit "defaultCoderConfig has sensible defaults" <| do
+ let cfg = defaultCoderConfig
+ coderMaxIterations cfg Test.@=? 50
+ coderMaxCostCents cfg Test.@=? 200
+ ]
+
+-- | Configuration for the coder agent
+data CoderConfig = CoderConfig
+ { coderTaskId :: Text,
+ coderExtraInstructions :: Maybe Text,
+ coderMaxIterations :: Int,
+ coderMaxCostCents :: Int,
+ coderVerbose :: Bool,
+ coderOnActivity :: Text -> IO (),
+ coderOnOutput :: Text -> IO ()
+ }
+
+defaultCoderConfig :: CoderConfig
+defaultCoderConfig =
+ CoderConfig
+ { coderTaskId = "",
+ coderExtraInstructions = Nothing,
+ coderMaxIterations = 50,
+ coderMaxCostCents = 200,
+ coderVerbose = True,
+ coderOnActivity = \msg -> TextIO.hPutStrLn IO.stderr <| "[coder] " <> msg,
+ coderOnOutput = TextIO.putStrLn
+ }
+
+-- | Result of running the coder
+data CoderResult
+ = CoderSuccess Text -- ^ Final message from agent
+ | CoderError Text -- ^ Error message
+ | CoderCompilationFailed Text -- ^ Code changes made but compilation failed
+ deriving (Show, Eq)
+
+-- | Run the coder agent on a task
+runCoder :: CoderConfig -> IO CoderResult
+runCoder cfg = do
+ let taskId = coderTaskId cfg
+ onActivity = coderOnActivity cfg
+
+ -- Claim task
+ onActivity <| "Claiming task " <> taskId <> "..."
+ allTasks <- Task.loadTasks
+ case Task.findTask taskId allTasks of
+ Nothing -> pure <| CoderError <| "Task not found: " <> taskId
+ Just task -> do
+ -- Update status to in-progress
+ Task.updateTaskStatus taskId Task.InProgress []
+
+ -- Build the prompt
+ let prompt = buildCoderPrompt task (coderExtraInstructions cfg)
+
+ onActivity <| "Task: " <> Task.taskTitle task
+ case Task.taskNamespace task of
+ Just ns -> onActivity <| "Namespace: " <> ns
+ Nothing -> pure ()
+
+ -- Run the agent
+ onActivity "Starting coder agent..."
+ result <- runCoderAgent cfg prompt
+
+ case result of
+ Left err -> pure <| CoderError err
+ Right response -> do
+ -- Post-coder verification
+ verifyResult <- verifyCompilation cfg task
+ case verifyResult of
+ Left err -> pure <| CoderCompilationFailed err
+ Right () -> pure <| CoderSuccess response
+
+-- | Build the prompt for the coder agent
+buildCoderPrompt :: Task.Task -> Maybe Text -> Text
+buildCoderPrompt task mExtraInstructions =
+ let taskId = Task.taskId task
+ title = Task.taskTitle task
+ desc = Task.taskDescription task
+ mNamespace = Task.taskNamespace task
+ comments = Task.taskComments task
+
+ basePrompt =
+ Text.unlines
+ [ "You are working on task " <> taskId <> ": " <> title,
+ "",
+ "## Description",
+ desc
+ ]
+
+ namespaceSection = case mNamespace of
+ Just ns ->
+ Text.unlines
+ [ "",
+ "## Namespace",
+ "Focus on: " <> ns
+ ]
+ Nothing -> ""
+
+ commentsSection =
+ if null comments
+ then ""
+ else
+ Text.unlines
+ [ "",
+ "## Previous Feedback",
+ Text.unlines <| map formatComment comments
+ ]
+
+ extraSection = case mExtraInstructions of
+ Just extra ->
+ Text.unlines
+ [ "",
+ "## Additional Instructions",
+ extra
+ ]
+ Nothing -> ""
+
+ guidelines = case mNamespace of
+ Just ns ->
+ Text.unlines
+ [ "",
+ "## Guidelines",
+ "- Make the necessary code changes",
+ "- You MUST run `bild " <> ns <> "` to verify compilation before finishing",
+ "- Fix any compilation errors before declaring the task complete",
+ "- Do NOT commit - a reviewer will handle that"
+ ]
+ Nothing ->
+ Text.unlines
+ [ "",
+ "## Guidelines",
+ "- Make the necessary changes",
+ "- Do NOT commit - a reviewer will handle that"
+ ]
+ in basePrompt <> namespaceSection <> commentsSection <> extraSection <> guidelines
+ where
+ formatComment :: Task.Comment -> Text
+ formatComment c =
+ "[" <> tshow (Task.commentCreatedAt c) <> "] " <> tshow (Task.commentAuthor c) <> ": " <> Task.commentText c
+
+-- | Run the agent with the given prompt
+runCoderAgent :: CoderConfig -> Text -> IO (Either Text Text)
+runCoderAgent cfg prompt = do
+ -- Resolve provider (prefer claude-code for cost efficiency)
+ provider <- resolveCoderProvider
+
+ case provider of
+ Left err -> pure <| Left err
+ Right prov -> do
+ -- Get web search tool if available
+ mKagiKey <- Environment.lookupEnv "KAGI_API_KEY"
+ let webSearchTools = case mKagiKey of
+ Just key -> [WebSearch.webSearchTool (Text.pack key)]
+ Nothing -> []
+ tools = Tools.allTools <> webSearchTools
+
+ -- Build agent config
+ let agentConfig =
+ Engine.defaultAgentConfig
+ { Engine.agentSystemPrompt = coderSystemPrompt,
+ Engine.agentTools = tools,
+ Engine.agentMaxIterations = coderMaxIterations cfg,
+ Engine.agentGuardrails =
+ Engine.defaultGuardrails
+ { Engine.guardrailMaxCostCents = fromIntegral (coderMaxCostCents cfg)
+ }
+ }
+
+ engineConfig =
+ Engine.defaultEngineConfig
+ { Engine.engineOnActivity =
+ if coderVerbose cfg
+ then coderOnActivity cfg
+ else \_ -> pure (),
+ Engine.engineOnToolCall =
+ if coderVerbose cfg
+ then \name args -> coderOnActivity cfg <| "Tool: " <> name <> " " <> Text.take 80 args
+ else \_ _ -> pure ()
+ }
+
+ -- Run agent
+ result <- Engine.runAgentWithProvider engineConfig prov agentConfig prompt
+
+ case result of
+ Left err -> pure <| Left err
+ Right response -> case Engine.resultError response of
+ Just err -> pure <| Left err
+ Nothing -> pure <| Right (Engine.resultFinalMessage response)
+
+-- | System prompt for the coder agent
+coderSystemPrompt :: Text
+coderSystemPrompt =
+ Text.unlines
+ [ "You are a coding agent. Your job is to implement code changes for tasks.",
+ "",
+ "## Process",
+ "",
+ "1. Read AGENTS.md to understand project conventions",
+ "2. Load the Coder skill from Omni/Ide/Coder.md for detailed instructions",
+ "3. Understand the task requirements",
+ "4. Make the necessary code changes",
+ "5. Run `bild` to verify compilation",
+ "6. Fix any errors until the code compiles",
+ "",
+ "## Guidelines",
+ "",
+ "- Be thorough but focused on the task",
+ "- Always verify your changes compile",
+ "- Do NOT commit - a reviewer will handle that"
+ ]
+
+-- | Resolve provider for coder (prefers claude-code if available)
+resolveCoderProvider :: IO (Either Text Provider.Provider)
+resolveCoderProvider = do
+ -- Check for explicit default provider
+ defaultProvider <- Environment.lookupEnv "AGENT_DEFAULT_PROVIDER"
+ case defaultProvider of
+ Just "claude-code" -> resolveClaudeCode
+ Just "anthropic" -> resolveAnthropic
+ Just "openrouter" -> resolveOpenRouter
+ _ -> do
+ -- Try claude-code first (cheapest with Claude Max)
+ ccResult <- resolveClaudeCode
+ case ccResult of
+ Right prov -> pure <| Right prov
+ Left _ -> do
+ -- Fall back to other providers
+ anthropicResult <- resolveAnthropic
+ case anthropicResult of
+ Right prov -> pure <| Right prov
+ Left _ -> resolveOpenRouter
+ where
+ resolveClaudeCode = do
+ tokenResult <- Auth.getValidToken
+ case tokenResult of
+ Right token -> pure <| Right <| Provider.anthropicOAuthProvider token "claude-sonnet-4-20250514"
+ Left err -> pure <| Left err
+
+ resolveAnthropic = do
+ mKey <- Environment.lookupEnv "ANTHROPIC_API_KEY"
+ case mKey of
+ Just key -> pure <| Right <| Provider.anthropicProvider (Text.pack key) "claude-sonnet-4-20250514"
+ Nothing -> pure <| Left "ANTHROPIC_API_KEY not set"
+
+ resolveOpenRouter = do
+ mKey <- Environment.lookupEnv "OPENROUTER_API_KEY"
+ case mKey of
+ Just key -> pure <| Right <| Provider.openRouterProvider (Text.pack key) "anthropic/claude-sonnet-4"
+ Nothing -> pure <| Left "No provider available (need AGENT_DEFAULT_PROVIDER, ANTHROPIC_API_KEY, or OPENROUTER_API_KEY)"
+
+-- | Verify compilation after coder changes
+verifyCompilation :: CoderConfig -> Task.Task -> IO (Either Text ())
+verifyCompilation cfg task = do
+ case Task.taskNamespace task of
+ Nothing -> pure <| Right () -- No namespace, skip verification
+ Just ns -> do
+ let nsPath = Text.unpack ns
+ isFile <- Directory.doesFileExist nsPath
+ isDir <- Directory.doesDirectoryExist nsPath
+
+ if isDir
+ then do
+ coderOnActivity cfg <| "Skipping bild verification for " <> ns <> " (directory)"
+ pure <| Right ()
+ else
+ if not isFile
+ then pure <| Right () -- File doesn't exist, skip
+ else case Text.takeWhileEnd (/= '.') ns of
+ fileExt
+ | fileExt `elem` ["hs", "py", "nix", "lisp"] -> do
+ coderOnActivity cfg "Verifying compilation..."
+ (exitCode, _, stderrOutput) <-
+ Process.readProcessWithExitCode "bild" [nsPath] ""
+ case exitCode of
+ Exit.ExitSuccess -> do
+ coderOnActivity cfg "Compilation verified"
+ pure <| Right ()
+ Exit.ExitFailure _ ->
+ pure <| Left <| "Compilation failed:\n" <> Text.pack stderrOutput
+ | otherwise -> do
+ coderOnActivity cfg <| "Skipping bild verification for " <> ns <> " (not a buildable file type)"
+ pure <| Right ()