summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-12-19 16:09:20 -0500
committerBen Sima <ben@bensima.com>2025-12-19 16:09:20 -0500
commit37d6503342ef9e171fef88960f7baceaa4d1a641 (patch)
treee8e4c93f6dc5bd982e1fa6b3a2c88125844b2669
parent826f97707da555b60e210182859ec83d995b9817 (diff)
Add prompt templating system with mustache
- Add promptsDir to Paths.hs for $AVA_DATA_ROOT/prompts/ - Create Omni.Agent.Prompts module with: - Mustache template loading and rendering - Automatic partial resolution via automaticCompile - Frontmatter/metadata parsing for list command - Create omni-agent-prompt CLI for previewing prompts: - list: show all available prompts - render: render prompt with --var and --json context - Prompts use .mustache extension for automaticCompile compatibility - Partials referenced with full extension: {{> shared/memory.mustache}}
-rw-r--r--Omni/Agent/Paths.hs4
-rw-r--r--Omni/Agent/Prompts.hs235
-rw-r--r--Omni/Agent/Prompts/Cli.hs110
3 files changed, 349 insertions, 0 deletions
diff --git a/Omni/Agent/Paths.hs b/Omni/Agent/Paths.hs
index 6df6991..d8def78 100644
--- a/Omni/Agent/Paths.hs
+++ b/Omni/Agent/Paths.hs
@@ -8,6 +8,7 @@
module Omni.Agent.Paths
( avaDataRoot,
skillsDir,
+ promptsDir,
outreachDir,
userScratchRoot,
userScratchDir,
@@ -33,6 +34,9 @@ avaDataRoot =
skillsDir :: FilePath
skillsDir = avaDataRoot </> "skills"
+promptsDir :: FilePath
+promptsDir = avaDataRoot </> "prompts"
+
outreachDir :: FilePath
outreachDir = avaDataRoot </> "outreach"
diff --git a/Omni/Agent/Prompts.hs b/Omni/Agent/Prompts.hs
new file mode 100644
index 0000000..231df3c
--- /dev/null
+++ b/Omni/Agent/Prompts.hs
@@ -0,0 +1,235 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Prompt templating system for Ava agents.
+--
+-- Prompts are stored as markdown files with mustache templating in
+-- @$AVA_DATA_ROOT/prompts/@. They support:
+--
+-- - Variables: @{{var_name}}@
+-- - Sections: @{{#maybe_field}}...{{/maybe_field}}@
+-- - Partials: @{{> shared/memory}}@
+--
+-- Directory structure:
+-- prompts/
+-- ├── shared/ -- Reusable partials
+-- │ ├── memory.md
+-- │ └── formatting/
+-- │ └── telegram.md
+-- ├── agents/
+-- │ └── telegram/
+-- │ └── system.md
+-- └── subagents/
+-- ├── generic/
+-- │ └── system.md
+-- └── coder/
+-- └── system.md
+--
+-- : out omni-agent-prompts
+-- : dep aeson
+-- : dep directory
+-- : dep mustache
+-- : dep text
+module Omni.Agent.Prompts
+ ( -- * Types
+ PromptMetadata (..),
+ PromptTemplate (..),
+
+ -- * Loading
+ loadPrompt,
+ loadPromptPure,
+
+ -- * Rendering
+ renderPrompt,
+ renderPromptWith,
+
+ -- * Listing
+ listPrompts,
+
+ -- * Paths
+ promptsDir,
+ promptPath,
+
+ -- * Testing
+ main,
+ test,
+ )
+where
+
+import Alpha
+import Data.Aeson ((.=))
+import qualified Data.Aeson as Aeson
+import qualified Data.List as List
+import qualified Data.Text as Text
+import qualified Data.Text.IO as TextIO
+import qualified Omni.Agent.Paths as Paths
+import qualified Omni.Test as Test
+import qualified System.Directory as Dir
+import System.FilePath ((</>))
+import qualified System.FilePath as FilePath
+import qualified Text.Mustache as Mustache
+import Text.Mustache.Types (Template)
+
+main :: IO ()
+main = Test.run test
+
+test :: Test.Tree
+test =
+ Test.group
+ "Omni.Agent.Prompts"
+ [ Test.unit "promptPath constructs correct path" <| do
+ let path = promptPath "agents/telegram/system"
+ ("agents/telegram/system.md" `List.isSuffixOf` path) Test.@=? True,
+ Test.unit "parsePromptMetadata parses frontmatter" <| do
+ let content =
+ "---\n\
+ \name: test-prompt\n\
+ \description: A test prompt\n\
+ \---\n\
+ \Hello {{name}}!"
+ case splitFrontmatter content of
+ (yaml, body) -> do
+ ("name:" `Text.isInfixOf` yaml) Test.@=? True
+ ("Hello" `Text.isInfixOf` body) Test.@=? True,
+ Test.unit "simple mustache substitution works" <| do
+ let tpl = "Hello {{name}}!"
+ case Mustache.compileTemplate "test" tpl of
+ Left _ -> Test.assertFailure "Failed to compile template"
+ Right t -> do
+ let result = Mustache.substitute t (Aeson.object ["name" .= ("World" :: Text)])
+ result Test.@=? "Hello World!"
+ ]
+
+promptsDir :: FilePath
+promptsDir = Paths.promptsDir
+
+promptPath :: Text -> FilePath
+promptPath pid = promptsDir </> Text.unpack pid FilePath.<.> "mustache"
+
+data PromptMetadata = PromptMetadata
+ { pmName :: Maybe Text,
+ pmDescription :: Maybe Text,
+ pmVars :: [Text]
+ }
+ deriving (Show, Eq, Generic)
+
+instance Aeson.ToJSON PromptMetadata where
+ toJSON m =
+ Aeson.object
+ [ "name" .= pmName m,
+ "description" .= pmDescription m,
+ "vars" .= pmVars m
+ ]
+
+data PromptTemplate = PromptTemplate
+ { ptId :: Text,
+ ptPath :: FilePath,
+ ptMetadata :: PromptMetadata,
+ ptTemplate :: Template
+ }
+ deriving (Show)
+
+splitFrontmatter :: Text -> (Text, Text)
+splitFrontmatter content =
+ let stripped = Text.strip content
+ in if Text.isPrefixOf "---" stripped
+ then
+ let afterFirst = Text.drop 3 stripped
+ (yamlPart, rest) = Text.breakOn "---" (Text.stripStart afterFirst)
+ in if Text.null rest
+ then ("", content)
+ else (Text.strip yamlPart, Text.strip (Text.drop 3 rest))
+ else ("", content)
+
+parsePromptMetadata :: Text -> PromptMetadata
+parsePromptMetadata yaml =
+ let kvPairs = parseKvLines (Text.lines yaml)
+ getName = List.lookup "name" kvPairs
+ getDesc = List.lookup "description" kvPairs
+ in PromptMetadata
+ { pmName = getName,
+ pmDescription = getDesc,
+ pmVars = []
+ }
+ where
+ parseKvLines :: [Text] -> [(Text, Text)]
+ parseKvLines = mapMaybe parseKvLine
+
+ parseKvLine :: Text -> Maybe (Text, Text)
+ parseKvLine line = do
+ let (key, rest) = Text.breakOn ":" line
+ guard (not (Text.null rest))
+ let value = Text.strip (Text.drop 1 rest)
+ guard (not (Text.null key))
+ pure (Text.strip key, value)
+
+loadPrompt :: Text -> IO (Either Text PromptTemplate)
+loadPrompt pid = do
+ let path = promptPath pid
+ exists <- Dir.doesFileExist path
+ if not exists
+ then pure <| Left ("Prompt not found: " <> pid <> " (" <> Text.pack path <> ")")
+ else do
+ content <- TextIO.readFile path
+ pure <| loadPromptPure pid path content
+
+loadPromptPure :: Text -> FilePath -> Text -> Either Text PromptTemplate
+loadPromptPure pid path content =
+ let (yamlPart, body) = splitFrontmatter content
+ meta = parsePromptMetadata yamlPart
+ in case Mustache.compileTemplate (Text.unpack pid) body of
+ Left err -> Left ("Mustache compile error: " <> Text.pack (show err))
+ Right tpl ->
+ Right
+ PromptTemplate
+ { ptId = pid,
+ ptPath = path,
+ ptMetadata = meta,
+ ptTemplate = tpl
+ }
+
+renderPromptWith :: PromptTemplate -> Aeson.Value -> Text
+renderPromptWith pt = Mustache.substitute (ptTemplate pt)
+
+renderPrompt :: Text -> Aeson.Value -> IO (Either Text Text)
+renderPrompt pid ctx = do
+ let path = promptPath pid
+ relativePath = Text.unpack pid FilePath.<.> "mustache"
+ exists <- Dir.doesFileExist path
+ if not exists
+ then pure <| Left ("Prompt not found: " <> pid <> " (" <> Text.pack path <> ")")
+ else do
+ absPromptsDir <- Dir.makeAbsolute promptsDir
+ eTpl <- Mustache.automaticCompile [absPromptsDir] relativePath
+ case eTpl of
+ Left err -> pure <| Left ("Mustache compile error: " <> Text.pack (show err))
+ Right tpl -> pure <| Right <| Mustache.substitute tpl ctx
+
+listPrompts :: IO [(Text, PromptMetadata)]
+listPrompts = listPromptsInDir promptsDir ""
+
+listPromptsInDir :: FilePath -> Text -> IO [(Text, PromptMetadata)]
+listPromptsInDir baseDir prefix = do
+ exists <- Dir.doesDirectoryExist baseDir
+ if not exists
+ then pure []
+ else do
+ entries <- Dir.listDirectory baseDir
+ results <-
+ forM entries <| \entry -> do
+ let fullPath = baseDir </> entry
+ isDir <- Dir.doesDirectoryExist fullPath
+ if isDir
+ then listPromptsInDir fullPath (if Text.null prefix then Text.pack entry else prefix <> "/" <> Text.pack entry)
+ else
+ if FilePath.takeExtension entry == ".mustache"
+ then do
+ let name = FilePath.dropExtension entry
+ pid = if Text.null prefix then Text.pack name else prefix <> "/" <> Text.pack name
+ content <- TextIO.readFile fullPath
+ let (yamlPart, _) = splitFrontmatter content
+ meta = parsePromptMetadata yamlPart
+ pure [(pid, meta)]
+ else pure []
+ pure (concat results)
diff --git a/Omni/Agent/Prompts/Cli.hs b/Omni/Agent/Prompts/Cli.hs
new file mode 100644
index 0000000..c0a792f
--- /dev/null
+++ b/Omni/Agent/Prompts/Cli.hs
@@ -0,0 +1,110 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | CLI tool for previewing and debugging prompt templates.
+--
+-- Usage:
+-- omni-agent-prompt list
+-- omni-agent-prompt agents/telegram/system
+-- omni-agent-prompt subagents/coder/system --var namespace=Omni/Jr --var task="Add feature"
+-- omni-agent-prompt subagents/generic/system --json '{"task":"Research X","role_description":"research"}'
+--
+-- : out omni-agent-prompt
+-- : dep aeson
+-- : dep bytestring
+-- : dep text
+-- : dep mustache
+-- : dep unordered-containers
+module Omni.Agent.Prompts.Cli (main) where
+
+import Alpha
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as TE
+import qualified Data.Text.IO as TextIO
+import qualified Omni.Agent.Prompts as Prompts
+import qualified System.Environment as Env
+import qualified System.Exit as Exit
+
+main :: IO ()
+main = do
+ args <- Env.getArgs
+ case args of
+ [] -> usage
+ ["--help"] -> usage
+ ["-h"] -> usage
+ ["list"] -> listPrompts
+ (pid : rest) -> renderPromptCmd (Text.pack pid) rest
+
+usage :: IO ()
+usage = do
+ putText "omni-agent-prompt - Preview and debug prompt templates"
+ putText ""
+ putText "Usage:"
+ putText " omni-agent-prompt list"
+ putText " omni-agent-prompt <prompt-id> [--var key=value]... [--json '{...}']"
+ putText ""
+ putText "Examples:"
+ putText " omni-agent-prompt agents/telegram/system"
+ putText " omni-agent-prompt subagents/coder/system --var namespace=Omni/Jr --var task='Add feature'"
+ putText " omni-agent-prompt subagents/generic/system --json '{\"task\":\"Research X\"}'"
+ putText ""
+ putText <| "Prompts directory: " <> Text.pack Prompts.promptsDir
+
+listPrompts :: IO ()
+listPrompts = do
+ prompts <- Prompts.listPrompts
+ if null prompts
+ then do
+ putText "No prompts found."
+ putText <| "Prompts directory: " <> Text.pack Prompts.promptsDir
+ else do
+ putText "Available prompts:"
+ putText ""
+ forM_ prompts <| \(pid, meta) -> do
+ let desc = fromMaybe "" (Prompts.pmDescription meta)
+ if Text.null desc
+ then putText <| " " <> pid
+ else putText <| " " <> pid <> " - " <> desc
+
+renderPromptCmd :: Text -> [String] -> IO ()
+renderPromptCmd pid args = do
+ let (jsonCtx, kvPairs) = parseArgs args
+ ctx <- case jsonCtx of
+ Nothing -> pure <| buildContext kvPairs
+ Just jsonStr -> case Aeson.decode (BL.fromStrict (TE.encodeUtf8 jsonStr)) of
+ Nothing -> do
+ putText <| "Error: Invalid JSON: " <> jsonStr
+ Exit.exitFailure
+ Just obj -> pure <| mergeContext obj kvPairs
+
+ result <- Prompts.renderPrompt pid ctx
+ case result of
+ Left err -> do
+ putText <| "Error: " <> err
+ Exit.exitFailure
+ Right rendered -> TextIO.putStrLn rendered
+
+parseArgs :: [String] -> (Maybe Text, [(Text, Text)])
+parseArgs = go Nothing []
+ where
+ go json kvs [] = (json, reverse kvs)
+ go _json kvs ("--json" : val : rest) = go (Just (Text.pack val)) kvs rest
+ go json kvs ("--var" : kv : rest) =
+ case Text.breakOn "=" (Text.pack kv) of
+ (k, v)
+ | not (Text.null v) -> go json ((k, Text.drop 1 v) : kvs) rest
+ | otherwise -> go json kvs rest
+ go json kvs (_ : rest) = go json kvs rest
+
+buildContext :: [(Text, Text)] -> Aeson.Value
+buildContext kvs =
+ Aeson.Object <| KeyMap.fromList [(Key.fromText k, Aeson.String v) | (k, v) <- kvs]
+
+mergeContext :: Aeson.Value -> [(Text, Text)] -> Aeson.Value
+mergeContext (Aeson.Object obj) kvs =
+ Aeson.Object <| foldr (\(k, v) m -> KeyMap.insert (Key.fromText k) (Aeson.String v) m) obj kvs
+mergeContext _ kvs = buildContext kvs