diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-19 16:09:20 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-19 16:09:20 -0500 |
| commit | 37d6503342ef9e171fef88960f7baceaa4d1a641 (patch) | |
| tree | e8e4c93f6dc5bd982e1fa6b3a2c88125844b2669 /Omni | |
| parent | 826f97707da555b60e210182859ec83d995b9817 (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}}
Diffstat (limited to 'Omni')
| -rw-r--r-- | Omni/Agent/Paths.hs | 4 | ||||
| -rw-r--r-- | Omni/Agent/Prompts.hs | 235 | ||||
| -rw-r--r-- | Omni/Agent/Prompts/Cli.hs | 110 |
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 |
