packages feed

seihou-core-0.6.0.0: src/Seihou/Core/AgentPrompt.hs

module Seihou.Core.AgentPrompt
  ( validateAgentPrompt,
    checkAgentPromptNameFormat,
    checkAgentPromptVersionPresent,
    checkAgentPromptBodyNonEmpty,
    checkAgentPromptUniqueVars,
    checkAgentPromptPromptRefs,
    checkAgentPromptCommandVars,
    checkAgentPromptGuidance,
    checkAgentPromptFiles,
    checkAgentPromptTags,
    checkAgentPromptAllowedTools,
    checkAgentPromptLaunch,
  )
where

import Data.Generics.Labels ()
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
          <> checkAgentPromptLaunch 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)
    ]

-- | Every field the @launch@ record does set must be non-blank. The declared
-- values themselves (which provider, which effort) are parsed by the CLI
-- layer, which owns those vocabularies; core only rejects blanks.
checkAgentPromptLaunch :: AgentPrompt -> [Text]
checkAgentPromptLaunch p = case p ^. #launch of
  Nothing -> []
  Just l ->
    blankErr "provider" (l ^. #provider)
      <> blankErr "model" (l ^. #model)
      <> blankErr "effort" (l ^. #effort)
      <> blankErr "mode" (l ^. #mode)
  where
    blankErr key value =
      [ "launch." <> key <> ", if specified, must not be empty"
      | Just v <- [value],
        T.null (T.strip v)
      ]

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