never executed always true always false
1 module PureClaw.Agent.Identity
2 ( -- * Identity types
3 AgentIdentity (..)
4 , defaultIdentity
5 -- * Loading
6 , loadIdentity
7 , loadIdentityFromText
8 -- * System prompt generation
9 , identitySystemPrompt
10 ) where
11
12 import Data.Maybe
13 import Data.Text (Text)
14 import Data.Text qualified as T
15 import Data.Text.IO qualified as TIO
16 import System.Directory
17
18 -- | Agent identity loaded from a SOUL.md file or configured defaults.
19 -- Controls the agent's system prompt, personality, and behavioral constraints.
20 data AgentIdentity = AgentIdentity
21 { _ai_name :: Text
22 , _ai_description :: Text
23 , _ai_instructions :: Text
24 , _ai_constraints :: [Text]
25 }
26 deriving stock (Show, Eq)
27
28 -- | A minimal default identity for when no SOUL.md is provided.
29 defaultIdentity :: AgentIdentity
30 defaultIdentity = AgentIdentity
31 { _ai_name = "PureClaw"
32 , _ai_description = "A helpful AI assistant."
33 , _ai_instructions = ""
34 , _ai_constraints = []
35 }
36
37 -- | Load an identity from a SOUL.md file at the given path.
38 -- Returns 'defaultIdentity' if the file does not exist.
39 loadIdentity :: FilePath -> IO AgentIdentity
40 loadIdentity path = do
41 exists <- doesFileExist path
42 if exists
43 then do
44 content <- TIO.readFile path
45 pure (loadIdentityFromText content)
46 else pure defaultIdentity
47
48 -- | Parse identity from SOUL.md markdown content. Extracts sections
49 -- by heading:
50 --
51 -- @
52 -- # Name
53 -- Agent name here
54 --
55 -- # Description
56 -- What this agent does.
57 --
58 -- # Instructions
59 -- How the agent should behave.
60 --
61 -- # Constraints
62 -- - Do not do X
63 -- - Always do Y
64 -- @
65 loadIdentityFromText :: Text -> AgentIdentity
66 loadIdentityFromText content =
67 let sections = parseSections content
68 get key = fromMaybe "" (lookup key sections)
69 constraints = maybe [] parseConstraints (lookup "constraints" sections)
70 in AgentIdentity
71 { _ai_name = T.strip (get "name")
72 , _ai_description = T.strip (get "description")
73 , _ai_instructions = T.strip (get "instructions")
74 , _ai_constraints = constraints
75 }
76
77 -- | Generate a system prompt from an identity.
78 identitySystemPrompt :: AgentIdentity -> Text
79 identitySystemPrompt ident =
80 let parts = filter (not . T.null)
81 [ if T.null (_ai_name ident)
82 then ""
83 else "You are " <> _ai_name ident <> "."
84 , _ai_description ident
85 , _ai_instructions ident
86 , if null (_ai_constraints ident)
87 then ""
88 else "Constraints:\n" <> T.unlines (map ("- " <>) (_ai_constraints ident))
89 ]
90 in T.intercalate "\n\n" parts
91
92 -- | Parse markdown into sections keyed by lowercase heading text.
93 -- Lines before the first heading are ignored.
94 parseSections :: Text -> [(Text, Text)]
95 parseSections content =
96 let ls = T.lines content
97 in go Nothing [] ls []
98 where
99 go :: Maybe Text -> [Text] -> [Text] -> [(Text, Text)] -> [(Text, Text)]
100 go currentKey accLines [] result =
101 case currentKey of
102 Nothing -> result
103 Just k -> result ++ [(k, T.unlines (reverse accLines))]
104 go currentKey accLines (l:rest) result
105 | Just heading <- parseHeading l =
106 let result' = case currentKey of
107 Nothing -> result
108 Just k -> result ++ [(k, T.unlines (reverse accLines))]
109 in go (Just (T.toLower heading)) [] rest result'
110 | otherwise = go currentKey (l : accLines) rest result
111
112 parseHeading :: Text -> Maybe Text
113 parseHeading line =
114 let stripped = T.stripStart line
115 in if T.isPrefixOf "# " stripped
116 then Just (T.strip (T.drop 2 stripped))
117 else Nothing
118
119 -- | Parse constraint lines from a markdown list.
120 -- Each line starting with @-@ is a constraint.
121 parseConstraints :: Text -> [Text]
122 parseConstraints content =
123 [ T.strip (T.drop 1 line)
124 | line <- T.lines content
125 , let trimmed = T.stripStart line
126 , T.isPrefixOf "- " trimmed
127 ]