← Back to task

Commit 32c6c803

commit 32c6c8032a0f1d43aed9593294f6c3e259e17b58
Author: Ben Sima <ben@bensima.com>
Date:   Thu Jan 1 21:35:13 2026

    Omni/Agentd: Add agentd CLI for container runtime
    
    agentd runs agent workflows in isolated OCI containers.
    
    Commands:
    - agentd run <spec.md> - Run a workflow spec
    - agentd ps - List running containers
    - agentd logs <id> - Show container logs
    - agentd stop <id> - Stop a container
    - agentd images - List available toolchain images
    - agentd test - Run tests
    
    Spec format: Markdown with YAML frontmatter defining:
    - toolchain (base/git/haskell)
    - workspace (path to mount rw)
    - model, provider, cost/iteration limits
    
    Container mounts:
    - workspace -> /workspace (rw)
    - repo root -> /repo (ro)
    - ~/.config/agent -> /root/.config/agent (ro)
    
    Task-Id: t-320.4, t-320.5, t-320.6

diff --git a/Omni/Agentd.hs b/Omni/Agentd.hs
new file mode 100644
index 00000000..b9732925
--- /dev/null
+++ b/Omni/Agentd.hs
@@ -0,0 +1,327 @@
+#!/usr/bin/env run.sh
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Agentd - Container runtime for agent workflows
+--
+-- Runs agent tasks in isolated OCI containers with proper mounts.
+--
+-- : out agentd
+-- : dep aeson
+-- : dep docopt
+-- : dep yaml
+module Omni.Agentd
+  ( main,
+    test,
+  )
+where
+
+import Alpha
+import qualified Data.Aeson as Aeson
+import qualified Data.ByteString.Char8 as BS
+import qualified Data.Text as Text
+import qualified Data.Text.IO as TextIO
+import qualified Data.Yaml as Yaml
+import qualified Omni.Cli as Cli
+import qualified Omni.Test as Test
+import qualified System.Console.Docopt as Docopt
+import qualified System.Directory as Dir
+import qualified System.Environment as Env
+import qualified System.Exit as Exit
+import qualified System.Process as Process
+
+-- | CLI help
+help :: Cli.Docopt
+help =
+  [Cli.docopt|
+agentd - Container runtime for agent workflows
+
+Usage:
+  agentd run <spec> [--verbose]
+  agentd ps
+  agentd logs <run-id>
+  agentd stop <run-id>
+  agentd images
+  agentd test
+  agentd (-h | --help)
+
+Commands:
+  run <spec>      Run a workflow spec (.md file)
+  ps              List running workflows
+  logs <run-id>   Show logs for a run
+  stop <run-id>   Stop a running workflow
+  images          List available toolchain images
+
+Options:
+  --verbose       Show container commands
+  -h --help       Show this help
+|]
+
+-- | Workflow spec parsed from markdown frontmatter
+data Spec = Spec
+  { specToolchain :: Text,
+    specWorkspace :: FilePath,
+    specModel :: Maybe Text,
+    specProvider :: Maybe Text,
+    specMaxCostCents :: Maybe Int,
+    specMaxIterations :: Maybe Int,
+    specTask :: Text
+  }
+  deriving (Show, Eq)
+
+-- | Frontmatter fields
+data Frontmatter = Frontmatter
+  { fmToolchain :: Text,
+    fmWorkspace :: Text,
+    fmModel :: Maybe Text,
+    fmProvider :: Maybe Text,
+    fmMaxCostCents :: Maybe Int,
+    fmMaxIterations :: Maybe Int
+  }
+  deriving (Show, Eq)
+
+instance Aeson.FromJSON Frontmatter where
+  parseJSON =
+    Aeson.withObject "Frontmatter" <| \o ->
+      Frontmatter
+        </ o
+        Aeson..: "toolchain"
+        <*> o
+        Aeson..: "workspace"
+        <*> o
+        Aeson..:? "model"
+        <*> o
+        Aeson..:? "provider"
+        <*> o
+        Aeson..:? "max_cost_cents"
+        <*> o
+        Aeson..:? "max_iterations"
+
+-- | Parse a spec file (markdown with YAML frontmatter)
+parseSpec :: Text -> Either Text Spec
+parseSpec content = do
+  -- Split frontmatter from body
+  let lines_ = Text.lines content
+  case lines_ of
+    ("---" : rest) ->
+      case break (== "---") rest of
+        (fmLines, "---" : bodyLines) -> do
+          let fmText = Text.unlines fmLines
+              body = Text.strip <| Text.unlines bodyLines
+          case Yaml.decodeEither' (BS.pack <| Text.unpack fmText) of
+            Left err -> Left <| "YAML parse error: " <> tshow err
+            Right fm ->
+              Right
+                Spec
+                  { specToolchain = fmToolchain fm,
+                    specWorkspace = Text.unpack <| fmWorkspace fm,
+                    specModel = fmModel fm,
+                    specProvider = fmProvider fm,
+                    specMaxCostCents = fmMaxCostCents fm,
+                    specMaxIterations = fmMaxIterations fm,
+                    specTask = body
+                  }
+        _ -> Left "Missing closing --- for frontmatter"
+    _ -> Left "Spec must start with --- (YAML frontmatter)"
+
+-- | Get the docker image name for a toolchain
+toolchainImage :: Text -> Text
+toolchainImage = \case
+  "base" -> "agent-base:latest"
+  "git" -> "agent-git:latest"
+  "haskell" -> "agent-haskell:latest"
+  other -> other -- allow custom image names
+
+-- | Run a workflow in a container
+runWorkflow :: Spec -> Bool -> IO Exit.ExitCode
+runWorkflow spec verbose = do
+  -- Get paths
+  coderoot <- Env.getEnv "CODEROOT"
+  home <- Env.getEnv "HOME"
+  workspaceAbs <- Dir.makeAbsolute (specWorkspace spec)
+
+  -- Build docker command
+  let image = toolchainImage (specToolchain spec)
+
+      -- Agent flags
+      modelFlag = maybe [] (\m -> ["--model=" <> Text.unpack m]) (specModel spec)
+      providerFlag = maybe [] (\p -> ["--provider=" <> Text.unpack p]) (specProvider spec)
+      costFlag = maybe [] (\c -> ["--max-cost=" <> show c]) (specMaxCostCents spec)
+      iterFlag = maybe [] (\i -> ["--max-iter=" <> show i]) (specMaxIterations spec)
+      agentFlags = modelFlag ++ providerFlag ++ costFlag ++ iterFlag
+
+      -- Docker run command
+      dockerArgs =
+        [ "run",
+          "--rm",
+          "-i",
+          -- Mount workspace (rw)
+          "-v",
+          workspaceAbs <> ":/workspace",
+          -- Mount repo (ro)
+          "-v",
+          coderoot <> ":/repo:ro",
+          -- Mount agent auth (ro)
+          "-v",
+          home <> "/.config/agent:/root/.config/agent:ro",
+          -- Pass through API keys
+          "-e",
+          "ANTHROPIC_API_KEY",
+          "-e",
+          "OPENROUTER_API_KEY",
+          "-e",
+          "KAGI_API_KEY",
+          -- Set working directory
+          "-w",
+          "/workspace",
+          -- Image
+          Text.unpack image,
+          -- Command: agent with task
+          "agent"
+        ]
+          ++ agentFlags
+          ++ [Text.unpack (specTask spec)]
+
+  when verbose <| do
+    TextIO.hPutStrLn stderr <| "docker " <> Text.unwords (map Text.pack dockerArgs)
+
+  -- Run container
+  (_, _, _, ph) <-
+    Process.createProcess
+      (Process.proc "docker" dockerArgs)
+        { Process.std_in = Process.Inherit,
+          Process.std_out = Process.Inherit,
+          Process.std_err = Process.Inherit
+        }
+  Process.waitForProcess ph
+
+-- | List running containers
+listRunning :: IO ()
+listRunning = do
+  let dockerArgs = ["ps", "--filter", "ancestor=agent-base", "--filter", "ancestor=agent-git", "--filter", "ancestor=agent-haskell", "--format", "{{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Command}}"]
+  Process.callProcess "docker" dockerArgs
+
+-- | Show logs for a container
+showLogs :: String -> IO ()
+showLogs runId = do
+  Process.callProcess "docker" ["logs", runId]
+
+-- | Stop a container
+stopContainer :: String -> IO ()
+stopContainer runId = do
+  Process.callProcess "docker" ["stop", runId]
+
+-- | List available images
+listImages :: IO ()
+listImages = do
+  TextIO.putStrLn "Available toolchain images:"
+  TextIO.putStrLn ""
+  -- Check which images exist
+  forM_ (["agent-base", "agent-git", "agent-haskell"] :: [String]) <| \img -> do
+    (code, _, _) <- Process.readProcessWithExitCode "docker" ["image", "inspect", img] ""
+    let status = if code == Exit.ExitSuccess then "✓" else "✗ (not built)" :: String
+    putStrLn <| "  " <> img <> ": " <> status
+  TextIO.putStrLn ""
+  TextIO.putStrLn "Build with: bild Omni/Agentd/Images/<Name>.nix"
+  TextIO.putStrLn "Load with:  docker load < _/nix/Omni/Agentd/Images/<Name>.nix"
+
+-- | Main entry point
+main :: IO ()
+main = do
+  args <- Docopt.parseArgsOrExit help =<< Env.getArgs
+
+  if args `Cli.has` Docopt.command "test"
+    then Test.run test
+    else
+      if args `Cli.has` Docopt.command "run"
+        then do
+          let specPath = Docopt.getArg args (Docopt.argument "spec")
+              verbose = args `Cli.has` Docopt.longOption "verbose"
+          case specPath of
+            Nothing -> do
+              TextIO.hPutStrLn stderr "Error: spec path required"
+              Exit.exitWith (Exit.ExitFailure 1)
+            Just path -> do
+              content <- TextIO.readFile path
+              case parseSpec content of
+                Left err -> do
+                  TextIO.hPutStrLn stderr <| "Error parsing spec: " <> err
+                  Exit.exitWith (Exit.ExitFailure 1)
+                Right spec -> do
+                  code <- runWorkflow spec verbose
+                  Exit.exitWith code
+        else
+          if args `Cli.has` Docopt.command "ps"
+            then listRunning
+            else
+              if args `Cli.has` Docopt.command "logs"
+                then do
+                  let runId = Docopt.getArg args (Docopt.argument "run-id")
+                  case runId of
+                    Nothing -> TextIO.hPutStrLn stderr "Error: run-id required"
+                    Just rid -> showLogs rid
+                else
+                  if args `Cli.has` Docopt.command "stop"
+                    then do
+                      let runId = Docopt.getArg args (Docopt.argument "run-id")
+                      case runId of
+                        Nothing -> TextIO.hPutStrLn stderr "Error: run-id required"
+                        Just rid -> stopContainer rid
+                    else
+                      if args `Cli.has` Docopt.command "images"
+                        then listImages
+                        else Docopt.exitWithUsage help
+
+-- | Tests
+test :: Test.Tree
+test =
+  Test.group
+    "Omni.Agentd"
+    [ Test.unit "parse spec" <| do
+        let content =
+              Text.unlines
+                [ "---",
+                  "toolchain: base",
+                  "workspace: .",
+                  "---",
+                  "Do something."
+                ]
+        case parseSpec content of
+          Left err -> Test.assertFailure <| Text.unpack err
+          Right spec -> do
+            "base" Test.@=? specToolchain spec
+            "." Test.@=? specWorkspace spec
+            "Do something." Test.@=? specTask spec,
+      Test.unit "parse spec with options" <| do
+        let content =
+              Text.unlines
+                [ "---",
+                  "toolchain: haskell",
+                  "workspace: ./src",
+                  "model: claude-sonnet-4",
+                  "provider: anthropic",
+                  "max_cost_cents: 200",
+                  "max_iterations: 100",
+                  "---",
+                  "Build the thing.",
+                  "",
+                  "With multiple lines."
+                ]
+        case parseSpec content of
+          Left err -> Test.assertFailure <| Text.unpack err
+          Right spec -> do
+            "haskell" Test.@=? specToolchain spec
+            "./src" Test.@=? specWorkspace spec
+            Just "claude-sonnet-4" Test.@=? specModel spec
+            Just "anthropic" Test.@=? specProvider spec
+            Just 200 Test.@=? specMaxCostCents spec
+            Just 100 Test.@=? specMaxIterations spec
+            True Test.@=? ("multiple lines" `Text.isInfixOf` specTask spec),
+      Test.unit "parse spec missing frontmatter" <| do
+        let content = "Just some text without frontmatter"
+        case parseSpec content of
+          Left _ -> pure ()
+          Right _ -> Test.assertFailure "Should have failed"
+    ]