seihou-core-0.4.0.0: src/Seihou/Core/AgentPrompt.hs
module Seihou.Core.AgentPrompt
( validateAgentPrompt,
checkAgentPromptNameFormat,
checkAgentPromptVersionPresent,
checkAgentPromptBodyNonEmpty,
checkAgentPromptUniqueVars,
checkAgentPromptPromptRefs,
checkAgentPromptCommandVars,
checkAgentPromptGuidance,
checkAgentPromptFiles,
checkAgentPromptTags,
checkAgentPromptAllowedTools,
)
where
import Data.Set qualified as Set
import Data.Text qualified as T
import Numeric.Natural (Natural)
import Seihou.Core.Expr (exprRefs)
import Seihou.Core.Module (isValidModuleName)
import Seihou.Core.Path (validateProjectRelativePath)
import Seihou.Core.Types
import Seihou.Prelude
import System.Directory (doesFileExist)
-- | Validate a decoded 'AgentPrompt' against the prompt authoring rules.
-- The 'FilePath' is the prompt's base directory (containing @prompt.dhall@).
validateAgentPrompt :: FilePath -> AgentPrompt -> IO (Either ModuleLoadError AgentPrompt)
validateAgentPrompt baseDir p = do
fileErrs <- checkAgentPromptFiles baseDir p
let pureErrs =
checkAgentPromptNameFormat p
<> checkAgentPromptVersionPresent p
<> checkAgentPromptBodyNonEmpty p
<> checkAgentPromptUniqueVars p
<> checkAgentPromptPromptRefs p
<> checkAgentPromptCommandVars p
<> checkAgentPromptGuidance p
<> checkAgentPromptTags p
<> checkAgentPromptAllowedTools p
allErrs = pureErrs <> fileErrs
pure $
if null allErrs
then Right p
else Left (ValidationError p.name allErrs)
checkAgentPromptNameFormat :: AgentPrompt -> [Text]
checkAgentPromptNameFormat p =
let n = p.name.unModuleName
in if T.null n || not (isValidModuleName n)
then ["prompt name must match [a-z][a-z0-9-]*, got: " <> n]
else []
checkAgentPromptVersionPresent :: AgentPrompt -> [Text]
checkAgentPromptVersionPresent p = case p.version of
Nothing -> []
Just v
| T.null (T.strip v) -> ["prompt version, if specified, must not be empty"]
| otherwise -> []
checkAgentPromptBodyNonEmpty :: AgentPrompt -> [Text]
checkAgentPromptBodyNonEmpty p
| T.null (T.strip p.prompt) = ["prompt body must not be empty"]
| otherwise = []
checkAgentPromptUniqueVars :: AgentPrompt -> [Text]
checkAgentPromptUniqueVars p =
let names = map (\d -> d.name.unVarName) p.vars
in map (\n -> "duplicate variable name: " <> n) (findDupes Set.empty Set.empty names)
checkAgentPromptPromptRefs :: AgentPrompt -> [Text]
checkAgentPromptPromptRefs p =
let varNames = Set.fromList (map (.name) p.vars)
in concatMap
( \prompt ->
if Set.member prompt.var varNames
then []
else ["prompt references undeclared variable: " <> prompt.var.unVarName]
)
p.prompts
checkAgentPromptCommandVars :: AgentPrompt -> [Text]
checkAgentPromptCommandVars p =
duplicateCommandVars <> concatMap checkCommandVar p.commandVars
where
commandNames = map (\cv -> cv.name.unVarName) p.commandVars
duplicateCommandVars =
map
(\n -> "duplicate command variable name: " <> n)
(findDupes Set.empty Set.empty commandNames)
checkCommandVar cv =
checkName cv <> checkRun cv <> checkWorkDir cv.workDir <> checkMaxBytes cv.maxBytes
checkName cv
| T.null (T.strip cv.name.unVarName) = ["command variable name must not be empty"]
| otherwise = []
checkRun cv
| T.null (T.strip cv.run) = ["command variable '" <> cv.name.unVarName <> "' run must not be empty"]
| otherwise = []
checkWorkDir Nothing = []
checkWorkDir (Just wd) =
case validateProjectRelativePath wd of
Left err -> ["command variable workDir " <> err]
Right _ -> []
checkMaxBytes Nothing = []
checkMaxBytes (Just n)
| n == 0 = ["command variable maxBytes must be greater than zero"]
| n > maxReasonableBytes = ["command variable maxBytes must be <= 1048576"]
| otherwise = []
maxReasonableBytes :: Natural
maxReasonableBytes = 1048576
checkAgentPromptGuidance :: AgentPrompt -> [Text]
checkAgentPromptGuidance p =
concatMap checkGuidance p.guidance
where
knownVars = Set.fromList (map (.name) p.vars <> map (.name) p.commandVars)
checkGuidance g =
checkTitle g <> checkBody g <> checkConditionRefs g
checkTitle g
| T.null (T.strip g.title) = ["guidance title must not be empty"]
| otherwise = []
checkBody g
| T.null (T.strip g.body) = ["guidance body must not be empty"]
| otherwise = []
checkConditionRefs g =
case g.condition of
Nothing -> []
Just cond ->
[ "guidance '" <> g.title <> "' references undeclared variable: " <> ref.unVarName
| (ref, _) <- exprRefs cond,
not (Set.member ref knownVars)
]
checkAgentPromptFiles :: FilePath -> AgentPrompt -> IO [Text]
checkAgentPromptFiles baseDir p =
concat
<$> mapM
( \pf -> do
let path = baseDir </> "files" </> pf.src
exists <- doesFileExist path
pure $
if exists
then []
else ["prompt file not found: " <> T.pack pf.src]
)
p.files
checkAgentPromptTags :: AgentPrompt -> [Text]
checkAgentPromptTags p =
[ "tag must not be empty"
| t <- p.tags,
T.null (T.strip t)
]
checkAgentPromptAllowedTools :: AgentPrompt -> [Text]
checkAgentPromptAllowedTools p = case p.allowedTools of
Nothing -> []
Just xs ->
[ "allowedTools entry must not be empty"
| t <- xs,
T.null (T.strip t)
]
findDupes :: Set.Set Text -> Set.Set Text -> [Text] -> [Text]
findDupes _ _ [] = []
findDupes seen reported (x : xs)
| Set.member x seen && not (Set.member x reported) = x : findDupes seen (Set.insert x reported) xs
| otherwise = findDupes (Set.insert x seen) reported xs