# A slightly more complex code Λ-gent
#! /usr/bin/env nix-shell
#! nix-shell --keep --pure -i runghc
#! nix-shell --keep --pure -p cacert curl git
#! nix-shell --keep --pure -p '(haskellPackages.extend (self: super: {pin = self.callHackageDirect { pkg = "A-gent"; ver = "0.11.0.17"; sha256 = "LRI7MWbMNyLyV931/bKoXY6H8juAB2RAJuBBvA8rDy8=";} {};})).ghcWithPackages (ps: with ps; [ pin ])'
As we did in the previous examples from the chat and echo tabs, we add, on the
top of the file, the following header as that will allow us to run the code as a
script. Likewise, we also pin it to the latest version of Λ-gent hackage
package.
We add the exact code options and language features to ensure clean and maintainable code as well as ensuring it ONLY executes safe-code and behaves nicely:
> {-# OPTIONS_GHC -Wall -Werror -fno-warn-orphans #-}
>
> {-# OPTIONS_GHC -frefinement-level-hole-fits=1 #-}
>
> {-# LANGUAGE Safe #-}
> {-# LANGUAGE NoGeneralizedNewtypeDeriving #-}
Notice how we added a new code option, that will help us define the script with Typed Holes Development (THD) in combination with a LSP:
Licensing information as well as library imports:
> --------------------------------------------------------------------------------
> --
> -- Λ-gent, (c) 2026 SPISE MISU ApS, https://spdx.org/licenses/SSPL-1.0
> --
> --------------------------------------------------------------------------------
>
> import Prelude hiding ( error, lines, mod )
>
> import Data.Either ( partitionEithers )
> import Data.List ( findIndex, isPrefixOf, tails )
>
> import qualified Agent.IO.Effects as EFF
>
> import Agent.Data.JSON ( Data )
> import qualified Agent.Data.JSON as JSON
> import Agent.IO.Restricted ( RIO )
> import qualified Agent.IO.Restricted as RIO
> import Agent.LLM
> ( Context (Context, mode)
> , Eval
> , Mode (Code)
> , replWithMode
> )
> import qualified Agent.LLM.Action as ACT
> import qualified Agent.LLM.Message as MSG
As with the chat example and in order to enforce structured interactions with
our LLM, we define (showable) data entities, that will be encoded / decoded to
JSON automatically by inferring the data types from the provided
specification. However, we will not define the Communicate data type as we
don’t expect to save any interaction with the LLM’s.
> data Message = Message
> { role :: String
> , content :: String
> } deriving (Data, Show)
> data Request = Request
> { model :: String
> , max_tokens :: Int
> , temperature :: Double
> , messages :: [Message]
> } deriving (Data, Show)
> data Response = Response
> { created :: Int
> , choices :: [Choice]
> , usage :: Usage
> } deriving (Data, Show)
>
> data Choice = Choice
> { index :: Int
> , finish_reason :: String
> , message :: Message
> } deriving (Data, Show)
>
> data Usage = Usage
> { prompt_tokens :: Int
> , completion_tokens :: Int
> , total_tokens :: Int
> } deriving (Data, Show)
> data Error = Error
> { error :: String
> } deriving (Data, Show)
Once we have a domain to interact with the LLM, we then define and execute our logic. In this sample, we will ONLY handle the code mode and we will do so separately in order to limit effects:
> eval :: Eval ()
> eval ctx msg =
> case ctx of
> Context { mode = Code } -> code ctx msg
> _______________________ -> return (ctx, ACT.text err)
> where
> mod = mode ctx
> err = show mod ++ " mode is not available for this agent"
For each message, with its corresponding action we are interested in supporting,
we will define their cases. We use a wildcard (_) for all the other we aren’t
going to use:
> code
> ::
> ( EFF.LlmCodeRead rio
> , EFF.LlmCodeTmpl rio
> , EFF.LlmCodePost rio
> , EFF.LlmCodeSave rio
> )
> => Context ()
> -> MSG.Message
> -> rio (Context (), ACT.Action)
> code ctx msg =
>
> case msg of
>
> MSG.Atom txt fs ->
> EFF.llmCodePut txt fs >>= \ ecf ->
> case ecf of
> Right o ->
> return
> ( ctx
> , ACT.text o
> )
> Left e ->
> return
> ( ctx
> , ACT.text e
> )
>
> MSG.List mfil ->
> EFF.llmCodeSeq mfil >>= \ ecfs ->
> case ecfs of
> Right fps ->
> return
> ( ctx
> , ACT.paths fps
> )
> Left errs ->
> return
> ( ctx
> , (ACT.text . unlines) errs
> )
>
> MSG.Path _ Nothing ->
> return
> ( ctx
> , ACT.text "Index is out of bounds"
> )
>
> MSG.Path _ (Just afp) ->
> EFF.llmCodeGet afp >>= \ ecf ->
> case ecf of
> Right f ->
> return
> ( ctx
> , ACT.file f
> )
> Left e ->
> return
> ( ctx
> , ACT.text e
> )
>
> MSG.Repo ->
> EFF.llmCodeGit >>= \ ebs ->
> case ebs of
> Right o ->
> return
> ( ctx
> , ACT.text o
> )
> Left e ->
> return
> ( ctx
> , ACT.text e
> )
>
> MSG.Root ->
> EFF.llmPathCWD >>= \ eroot ->
> case eroot of
> Just root ->
> return
> ( ctx
> , ACT.root root
> )
> Nothing ->
> return
> ( ctx
> , ACT.none
> )
>
> MSG.Send xmls ->
> -- NOTE: 84.70 GB LLM (max context length 256k)
> payloadToLLM ctx xmls 262144 0.7 "mlx-community/Qwen3-Coder-Next-8bit"
>
> MSG.Text txt ->
> -- NOTE: 70.00 GB LLM (max context length 256k)
> payloadToLLM ctx [txt] 262144 0.7 "mlx-community/Qwen3.6-35B-A3B-bf16"
>
> MSG.Tmpl (Just afps) ->
> EFF.llmCodeIns >>= \ cis ->
> EFF.llmCodeExa >>= \ ces ->
> mapM EFF.llmCodeGet afps >>= \ ecfs ->
> case partitionEithers ecfs of
> ( [ ], cfs ) ->
> return
> ( ctx
> , ACT.template parseFiles cis ces cfs
> )
> ( errs, ___ ) ->
> return
> ( ctx
> , (ACT.text . unlines) errs
> )
>
> ________ ->
> return
> ( ctx
> , ACT.text
> $ "The following message '" ++ show msg ++ "', is not supported"
> )
One of the features of defining these scripts with an IDE that supports LSP, is
that we can utilize Typed Hole Development. In this example, we have placed a
type hole (_) and the LSP will provide a list possible options:
> -- …
> -- MSG.List mfil ->
> -- EFF.llmCodeSeq mfil >>= \ ecfs ->
> -- case ecfs of
> -- Right fps ->
> -- return
> -- ( ctx
> -- , _ • Found hole: _ :: ACT.Action
> -- )
> -- Left errs ->
> -- return
> -- ( ctx
> -- , (ACT.text . unlines) errs
> -- )
> -- …
> --
> -- code.lhs:116:17: error: [GHC-88464]
> -- • Found hole: _ :: ACT.Action
> -- • In the first argument of ‘return’, namely ‘(ctx, _)’
> -- In the expression: return (ctx, _)
> -- In a case alternative: Right fps -> return (ctx, _)
> -- • Relevant bindings include
> -- fps :: ACT.FilePaths (bound at code.lhs:113:17)
> -- …
> -- Constraints include
> -- EFF.LlmCodeRead rio (from code.lhs:(82,3)-(91,35))
> -- EFF.LlmCodeTmpl rio (from code.lhs:(82,3)-(91,35))
> -- EFF.LlmCodePost rio (from code.lhs:(82,3)-(91,35))
> -- EFF.LlmCodeSave rio (from code.lhs:(82,3)-(91,35))
> -- Valid hole fits include
> -- …
> -- Valid refinement hole fits include
> -- …
> -- ACT.paths (_ :: ACT.FilePaths)
> -- where ACT.paths :: ACT.FilePaths -> ACT.Action
> -- (imported qualified from ‘Agent.LLM.Action’ at code.lhs:36:3-45)
> -- …
Due to the given binding types, there is ONLY a single case in which the
constraints hold. This approach will be very important to whenever the Auto
mode is added to the Λ-gent library.
> main :: IO ()
> main =
> replWithMode Code eval
Once again, we ONLY define effects (and dependencies) we are going to use.
instance EFF.LlmConf RIO where
llmPathCWD = RIO.llmPathCWD
> instance EFF.LlmCodeRoot RIO where
> llmCodeDir = RIO.llmCodeDir
>
> instance EFF.LlmCodeMask RIO where
> llmCodeMsk = RIO.llmCodeMsk
>
> instance EFF.LlmCodeTmpl RIO where
> llmCodeIns = RIO.llmCodeIns
> llmCodeExa = RIO.llmCodeExa
> instance EFF.LlmCodeRead RIO where
> llmCodeSeq = RIO.llmCodeSeq
> llmCodeGet = RIO.llmCodeGet
> llmCodeGit = RIO.llmCodeGit
> instance EFF.LlmCodeSave RIO where
> llmCodePut = RIO.llmCodePut
> instance EFF.LlmCodeConf RIO where
> llmCodeAPI = RIO.llmCodeAPI
> llmCodeKey = RIO.llmCodeKey
>
> instance EFF.LlmCodePost RIO where
> llmCodeWeb = RIO.llmCodeWeb
The prompt engineering case (LLM.Send xmls) as well as the vibe coding
case (LLM.Text txt) will re-use the same data interfaces to interact with the
LLM’s. Therefore we create a separate parameterized helper function:
> payloadToLLM
> :: EFF.LlmCodePost rio
> => Context a
> -> [String]
> -> Int
> -> Double
> -> String
> -> rio (Context a, ACT.Action)
> payloadToLLM ctx txts tok tem mod =
> EFF.llmCodeWeb (JSON.encode req) >>= \ eres ->
> case eres of
> Right json ->
> return
> ( ctx
> , case res of
> Left err ->
> ACT.text $ error err
> Right val ->
> ACT.text $ concatMap (content . message) $ choices val
> )
> where
> res =
> case JSON.decode json of
> Right a -> Right a
> Left JSON.InvalidJSON -> Left $ Error $ "Invalid: " ++ json
> Left JSON.DiffSchema ->
> case JSON.decode json of
> Right e ->
> Left e
> Left JSON.InvalidJSON ->
> Left $ Error $ "Invalid: " ++ json
> Left JSON.DiffSchema ->
> Left $ Error $ "Diff Schema: " ++ json
> Left err ->
> return (ctx, ACT.text err)
> where
> req =
> Request
> { model = mod
> , max_tokens = tok
> , temperature = tem
> , messages =
> map
> ( \ txt ->
> Message
> { role = "user"
> , content = txt
> }
> )
> txts
> }
You will notice that the logic is the exactly same as what we defined in the chat example from the homepage.
Finally, to the enforce strict guidelines and narrow outcome (NAI) for our
prompt engineering, we need, in the LLM.TemplateFiles case, to provide a
function that can transform LLM text to a list of tuples, with the first
element being a relative file path and the second one being the content lines
from a file. We provide instructions by storing files with the follow naming
convention: code_instructions_*.json in a llm folder in the root of the
git repository:
[ …
, "Header output MUST be the provided file path. Ex: # FILEPATH_GOES_HERE"
, "Body output MUST be a single JSON array. Ex: ```json [ LINES_GOES_HERE ]```"
]In order to not limit the audience, I have avoided the usage of parser combinators. Basically, we just look for a predefined separator, we then parse the text from current offset to the found separator. We keep performing the logic until end of string or no more matching cases:
> parseFiles
> :: String
> -> [(FilePath, [String])]
> parseFiles txt =
> (snd . partitionEithers) $ aux (fn sp np) np
> where
> sp = "```\n"
> np = txt ++ "\n"
> lp = length sp
> fn = (. tails) . findIndex . isPrefixOf
> aux midx cs =
> case midx of
> Nothing -> []
> Just idx ->
> par (take idx cs) : aux (fn sp rs) rs
> where
> rs = drop (idx + lp) cs
> par cs =
> case JSON.decode (g cs) :: Either JSON.DecodeError [String] of
> Right fls ->
> Right
> ( f cs
> , fls
> )
> Left err ->
> case err of
> JSON.InvalidJSON -> Left "Invalid JSON"
> JSON.DiffSchema -> Left "Diff JSON Schema"
> where
> f = takeWhile (/= '\n') . drop 2 . dropWhile (/= '#')
> g = drop 1 . dropWhile (/= '\n') . dropWhile (/= '`')
And that is it.
Note: Once again, by copying the text from above, except this note, and saving it to a
.lhsscript (*) file and converting it to an executable file withchmox +x code.lhs, you now execute it like this:LLM_PATH_CWD="…" LLM_CODE_LOCALHOST_API="http://…:8080/v1" ./code.lhsfrom a terminal.(*) - You MUST install the
nixpackage manager on your operating system for this script to work: https://nixos.org/download/
# Use case from executing the script
# Exit Λ-gent with /e or /exit. For more commands, type /? or /help.
Λ-code> /list Effects
0: src/Agent/IO/Effects.hs
Λ-code> /pile
0: src/Agent/IO/Effects.hs
Λ-code> /send Add Haddock to code file
# src/Agent/IO/Effects.hs
```json
[
"{-# OPTIONS_GHC -Wall -Werror #-}",
"",
"{-# LANGUAGE NoGeneralizedNewtypeDeriving #-}",
"{-# LANGUAGE Safe #-}",
"",
"--------------------------------------------------------------------------------",
…,
"--------------------------------------------------------------------------------",
"",
"-- | Class providing the ability to POST plan tooling requests to an LLM service.",
"class",
" ( LlmConf m",
" , LlmPlanConf m",
" )",
" => LlmPlanPost m",
" where",
" -- | Send a plan tooling request to the configured LLM service and return the",
" -- response.",
" --",
" -- Returns 'Left' with an error message on failure, or 'Right' with the",
" -- model's response string on success.",
" llmPlanWeb",
" :: String",
" -> m (Either String String)",
"",
"--------------------------------------------------------------------------------",
"--------------------------------------------------------------------------------"
]
```
Λ-code> /ruck
0: src/Agent/IO/Effects.hs
Λ-code> /atom Add Haddock to code file
# Temporary worktree branch:
## Adding:
HEAD is now at 145d69c Minor fixed but most important, excluding symbolic links from search
## Saving files:
* Ensuring folder exists:
/home/johndoe/temp/a-gent/git-repo/tmp/20260512-151450-747945186/src/Agent/IO
* Writing file to folder:
/home/johndoe/temp/a-gent/git-repo/tmp/20260512-151450-747945186/src/Agent/IO/Effects.hs
## Adding files and committing :
[20260512-151450-747945186 f5c5d19] Add Haddock to code file
1 file changed, 48 insertions(+)
## Removing:
Λ-code> /repo
branch.20260512-151450-747945186.description Add Haddock to code file
We can now, from our usual code tools, work with the created git branch:
git diff main 20260512-151450-747945186
diff --git a/src/Agent/IO/Effects.hs b/src/Agent/IO/Effects.hs
index 598d20f..e0268f0 100644
--- a/src/Agent/IO/Effects.hs
+++ b/src/Agent/IO/Effects.hs
@@ -40,31 +40,41 @@ import qualified Internal.LLM as LLM
--------------------------------------------------------------------------------
+-- | Class providing access to the current working directory for LLM tooling.
class
Monad m
=> LlmConf m
where
+ -- | Retrieve the current working directory root for LLM tooling, if available.
llmPathCWD
:: m (Maybe LLM.Root)
--------------------------------------------------------------------------------
…
--------------------------------------------------------------------------------
+-- | Class providing the ability to POST plan tooling requests to an LLM service.
class
( LlmConf m
, LlmPlanConf m
)
=> LlmPlanPost m
where
+ -- | Send a plan tooling request to the configured LLM service and return the
+ -- response.
+ --
+ -- Returns 'Left' with an error message on failure, or 'Right' with the
+ -- model's response string on success.
llmPlanWeb
:: String
-> m (Either String String)We could try to be sneaky and save code files outside of the specified folder
from EFF.llmCodeDir, which for this script has been defined as src:
Λ-code> /send Rename src folder to doc
# doc/Agent/IO/Effects.hs
Λ-code> /ruck
0: doc/Agent/IO/Effects.hs
Λ-code> /atom Rename src folder to doc
# Temporary worktree branch error(s):
## Adding:
No error
## Adding description:
No error
## Saving files:
doc/Agent/IO/Effects.hs doesn't start with src
## Adding files:
No error
## Committing:
## Removing:
No error
Λ-code> /repo
branch.20260512-151450-747945186.description Add Haddock to code file
branch.20260512-152448-083141513.description [ERROR]: Rename src folder to doc
We notice how this is not be allowed. The created branch, without files, has
also been tagged with a (local) git description as an erroneous branch.