langchain-hs-graph 0.0.5.0 → 0.0.6.0
raw patch · 12 files changed
+391/−307 lines, 12 filesdep −asyncdep −timedep ~langchain-hs-corePVP: major bump suggested
API removals or changes: PVP suggests a major version bump
Dependencies removed: async, time
Dependency ranges changed: langchain-hs-core
API changes (from Hackage documentation)
- Langchain.Graph.MultiAgent: embedSubGraphNode :: forall (m :: Type -> Type) subState parentState. (MonadIO m, MonadError LangchainError m) => NodeId -> StateGraph subState m -> (parentState -> subState) -> (parentState -> subState -> parentState) -> Node parentState m
- Langchain.Graph.MultiAgent: embedSubGraphNodeWithStart :: forall (m :: Type -> Type) subState parentState. (MonadIO m, MonadError LangchainError m) => NodeId -> NodeId -> StateGraph subState m -> (parentState -> subState) -> (parentState -> subState -> parentState) -> Node parentState m
- Langchain.Graph.MultiAgent: supervisorNode :: forall model (m :: Type -> Type) s. (ChatModel model, MonadIO m, MonadError LangchainError m) => model -> NodeId -> [(Text, NodeId)] -> (s -> Text) -> (Text -> s -> s) -> Node s m
- Langchain.Graph.Parallel: addParallelNodes :: forall (m :: Type -> Type) s. MonadIO m => NodeId -> [s -> IO (Either LangchainError s)] -> (s -> [s] -> s) -> StateGraph s m -> StateGraph s m
- Langchain.Graph.Parallel: parallelNode :: forall (m :: Type -> Type) s. MonadIO m => NodeId -> [s -> IO (Either LangchainError s)] -> (s -> [s] -> s) -> Node s m
+ Langchain.Graph.StateGraph: CompiledGraph :: !Map NodeId (Node s m) -> !Map NodeId (Edge s m) -> !StateReducer s -> !GraphConfig -> CompiledGraph s (m :: Type -> Type)
+ Langchain.Graph.StateGraph: GraphConfig :: !Int -> !Bool -> !Text -> IO () -> GraphConfig
+ Langchain.Graph.StateGraph: [compiledConfig] :: CompiledGraph s (m :: Type -> Type) -> !GraphConfig
+ Langchain.Graph.StateGraph: [compiledEdges] :: CompiledGraph s (m :: Type -> Type) -> !Map NodeId (Edge s m)
+ Langchain.Graph.StateGraph: [compiledNodes] :: CompiledGraph s (m :: Type -> Type) -> !Map NodeId (Node s m)
+ Langchain.Graph.StateGraph: [compiledReducer] :: CompiledGraph s (m :: Type -> Type) -> !StateReducer s
+ Langchain.Graph.StateGraph: [graphConfig] :: StateGraph s (m :: Type -> Type) -> !GraphConfig
+ Langchain.Graph.StateGraph: [logStepTransitions] :: GraphConfig -> !Bool
+ Langchain.Graph.StateGraph: [recursionLimit] :: GraphConfig -> !Int
+ Langchain.Graph.StateGraph: [stepLogger] :: GraphConfig -> !Text -> IO ()
+ Langchain.Graph.StateGraph: addChatNode :: forall c (m :: Type -> Type) s. (ChatModel c, MonadIO m) => NodeId -> c -> (s -> Text) -> (s -> Text -> s) -> StateGraph s m -> StateGraph s m
+ Langchain.Graph.StateGraph: addChatNodeWithConfig :: forall c (m :: Type -> Type) s. (ChatModel c, MonadIO m) => NodeId -> c -> ModelConfig c -> (s -> Text) -> (s -> Text -> s) -> StateGraph s m -> StateGraph s m
+ Langchain.Graph.StateGraph: addModelNode :: forall c (m :: Type -> Type) s. (ChatModel c, MonadIO m) => NodeId -> c -> (s -> [Message]) -> (s -> Message -> s) -> StateGraph s m -> StateGraph s m
+ Langchain.Graph.StateGraph: addModelNodeWithConfig :: forall c (m :: Type -> Type) s. (ChatModel c, MonadIO m) => NodeId -> c -> ModelConfig c -> (s -> [Message]) -> (s -> Message -> s) -> StateGraph s m -> StateGraph s m
+ Langchain.Graph.StateGraph: addRunnableNode :: forall r (m :: Type -> Type) s. (Runnable r m, Monad m) => NodeId -> r -> (s -> RunnableInput r) -> (s -> RunnableOutput r -> s) -> StateGraph s m -> StateGraph s m
+ Langchain.Graph.StateGraph: addRunnableTreeNode :: forall (m :: Type -> Type) i o s. MonadIO m => NodeId -> RunnableTree (ExceptT LangchainError IO) i o -> (s -> i) -> (s -> o -> s) -> StateGraph s m -> StateGraph s m
+ Langchain.Graph.StateGraph: addSubGraphNode :: forall (m :: Type -> Type) subState s. (MonadIO m, MonadError LangchainError m) => NodeId -> NodeId -> CompiledGraph subState m -> (s -> subState) -> (s -> subState -> s) -> StateGraph s m -> StateGraph s m
+ Langchain.Graph.StateGraph: addTransformNode :: forall (m :: Type -> Type) s. Monad m => NodeId -> (s -> s) -> StateGraph s m -> StateGraph s m
+ Langchain.Graph.StateGraph: data CompiledGraph s (m :: Type -> Type)
+ Langchain.Graph.StateGraph: data GraphConfig
+ Langchain.Graph.StateGraph: defaultGraphConfig :: GraphConfig
+ Langchain.Graph.StateGraph: runGraphWithConfig :: (MonadIO m, MonadError LangchainError m) => GraphConfig -> CompiledGraph s m -> NodeId -> s -> m s
+ Langchain.Graph.StateGraph: setRecursionLimit :: forall s (m :: Type -> Type). Int -> StateGraph s m -> StateGraph s m
+ Langchain.Graph.StateGraph: setStepLogger :: forall s (m :: Type -> Type). (Text -> IO ()) -> StateGraph s m -> StateGraph s m
- Langchain.Graph.HITL: resumeGraph :: (Checkpointer cp m, FromJSON s, ToJSON s, MonadIO m, MonadError LangchainError m) => StateGraph s m -> cp -> Text -> NodeId -> NodeId -> (s -> s) -> m s
+ Langchain.Graph.HITL: resumeGraph :: (Checkpointer cp m, FromJSON s, ToJSON s, MonadIO m, MonadError LangchainError m) => CompiledGraph s m -> cp -> Text -> NodeId -> NodeId -> (s -> s) -> m s
- Langchain.Graph.StateGraph: ConditionalEdge :: (s -> m (Either LangchainError NodeId)) -> Edge s (m :: Type -> Type)
+ Langchain.Graph.StateGraph: ConditionalEdge :: !s -> m (Either LangchainError NodeId) -> Edge s (m :: Type -> Type)
- Langchain.Graph.StateGraph: Node :: NodeId -> (s -> m (Either LangchainError s)) -> Node s (m :: Type -> Type)
+ Langchain.Graph.StateGraph: Node :: !NodeId -> !s -> m (Either LangchainError s) -> Node s (m :: Type -> Type)
- Langchain.Graph.StateGraph: StateGraph :: Map NodeId (Node s m) -> Map NodeId (Edge s m) -> StateReducer s -> StateGraph s (m :: Type -> Type)
+ Langchain.Graph.StateGraph: StateGraph :: !Map NodeId (Node s m) -> !Map NodeId (Edge s m) -> !StateReducer s -> !GraphConfig -> StateGraph s (m :: Type -> Type)
- Langchain.Graph.StateGraph: StaticEdge :: NodeId -> Edge s (m :: Type -> Type)
+ Langchain.Graph.StateGraph: StaticEdge :: !NodeId -> Edge s (m :: Type -> Type)
- Langchain.Graph.StateGraph: [graphEdges] :: StateGraph s (m :: Type -> Type) -> Map NodeId (Edge s m)
+ Langchain.Graph.StateGraph: [graphEdges] :: StateGraph s (m :: Type -> Type) -> !Map NodeId (Edge s m)
- Langchain.Graph.StateGraph: [graphNodes] :: StateGraph s (m :: Type -> Type) -> Map NodeId (Node s m)
+ Langchain.Graph.StateGraph: [graphNodes] :: StateGraph s (m :: Type -> Type) -> !Map NodeId (Node s m)
- Langchain.Graph.StateGraph: [graphReducer] :: StateGraph s (m :: Type -> Type) -> StateReducer s
+ Langchain.Graph.StateGraph: [graphReducer] :: StateGraph s (m :: Type -> Type) -> !StateReducer s
- Langchain.Graph.StateGraph: [nodeAction] :: Node s (m :: Type -> Type) -> s -> m (Either LangchainError s)
+ Langchain.Graph.StateGraph: [nodeAction] :: Node s (m :: Type -> Type) -> !s -> m (Either LangchainError s)
- Langchain.Graph.StateGraph: [nodeId] :: Node s (m :: Type -> Type) -> NodeId
+ Langchain.Graph.StateGraph: [nodeId] :: Node s (m :: Type -> Type) -> !NodeId
- Langchain.Graph.StateGraph: compileGraph :: forall s (m :: Type -> Type). StateGraph s m -> Either LangchainError (StateGraph s m)
+ Langchain.Graph.StateGraph: compileGraph :: forall s (m :: Type -> Type). StateGraph s m -> Either LangchainError (CompiledGraph s m)
- Langchain.Graph.StateGraph: runGraph :: (MonadIO m, MonadError LangchainError m) => StateGraph s m -> NodeId -> s -> m s
+ Langchain.Graph.StateGraph: runGraph :: (MonadIO m, MonadError LangchainError m) => CompiledGraph s m -> NodeId -> s -> m s
Files
- CHANGELOG.md +5/−0
- README.md +1/−1
- langchain-hs-graph.cabal +4/−11
- src/Langchain/Graph/Checkpointer.hs +1/−1
- src/Langchain/Graph/HITL.hs +5/−5
- src/Langchain/Graph/MultiAgent.hs +0/−96
- src/Langchain/Graph/Parallel.hs +0/−52
- src/Langchain/Graph/StateGraph.hs +297/−63
- test/Spec.hs +0/−2
- test/Test/Langchain/Graph/HITLSpec.hs +1/−1
- test/Test/Langchain/Graph/MultiAgentSpec.hs +0/−53
- test/Test/Langchain/Graph/StateGraphSpec.hs +77/−22
CHANGELOG.md view
@@ -2,6 +2,11 @@ All notable changes to this package will be documented in this file. +## 0.0.6.0 - 2026-10-02++- Updated bounds for GHC 9.12 and modern Stackage LTS releases.+- Standalone stack configuration and verified test suite.+ ## 0.0.5.0 - 2026-09-10 - Initial standalone Hackage release of `langchain-hs-graph`.
README.md view
@@ -18,7 +18,7 @@ ## Installation ```cabal-build-depends: langchain-hs-graph >= 0.0.5 && < 0.0.6+build-depends: langchain-hs-graph >= 0.0.6 && < 0.0.7 ``` ## License
langchain-hs-graph.cabal view
@@ -5,9 +5,9 @@ -- see: https://github.com/sol/hpack name: langchain-hs-graph-version: 0.0.5.0+version: 0.0.6.0 synopsis: Graph-based stateful agent orchestration engine for langchain-hs-description: StateGraph, StateReducer, Checkpointer, HITL interrupt/resume, and Multi-Agent supervisor patterns.+description: StateGraph, StateReducer, Checkpointer, and HITL interrupt/resume workflow patterns. category: Web, AI, Control homepage: https://github.com/tusharad/langchain-hs#readme bug-reports: https://github.com/tusharad/langchain-hs/issues@@ -34,8 +34,6 @@ Langchain.Graph.StateGraph Langchain.Graph.Checkpointer Langchain.Graph.HITL- Langchain.Graph.MultiAgent- Langchain.Graph.Parallel other-modules: Paths_langchain_hs_graph hs-source-dirs:@@ -55,16 +53,14 @@ ScopedTypeVariables build-depends: aeson >=2.0 && <3- , async ==2.2.* , base >=4.17 && <5 , bytestring >=0.10 && <0.13 , containers >=0.6 && <0.9- , langchain-hs-core ==0.0.5.*+ , langchain-hs-core ==0.0.6.* , mtl >=2.2 && <2.4 , sqlite-simple >=0.4.18 && <0.5 , stm ==2.5.* , text >=1.2 && <3- , time >=1.9 && <1.15 default-language: Haskell2010 test-suite langchain-hs-graph-test@@ -73,7 +69,6 @@ other-modules: Test.Langchain.Graph.CheckpointerSpec Test.Langchain.Graph.HITLSpec- Test.Langchain.Graph.MultiAgentSpec Test.Langchain.Graph.StateGraphSpec Paths_langchain_hs_graph hs-source-dirs:@@ -95,12 +90,11 @@ build-depends: QuickCheck >=2.14 , aeson >=2.0 && <3- , async ==2.2.* , base >=4.17 && <5 , bytestring >=0.10 && <0.13 , containers >=0.6 && <0.9 , filepath- , langchain-hs-core ==0.0.5.*+ , langchain-hs-core ==0.0.6.* , langchain-hs-graph , mtl >=2.2 && <2.4 , sqlite-simple >=0.4.18 && <0.5@@ -110,5 +104,4 @@ , tasty-quickcheck >=0.10 , temporary , text >=1.2 && <3- , time >=1.9 && <1.15 default-language: Haskell2010
src/Langchain/Graph/Checkpointer.hs view
@@ -32,7 +32,7 @@ import qualified Data.Text.Encoding as TE import Database.SQLite.Simple -import Langchain.Core.Error (LangchainError, internalError)+import Langchain.Error (LangchainError, internalError) import Langchain.Graph.StateGraph (NodeId) -- | Effect-polymorphic Checkpointer typeclass
src/Langchain/Graph/HITL.hs view
@@ -24,9 +24,9 @@ import Data.Text (Text) import qualified Data.Text as T -import Langchain.Core.Error (LangchainError (..), agentError, errorMessage)+import Langchain.Error (LangchainError (..), agentError, errorMessage) import Langchain.Graph.Checkpointer (Checkpointer (..))-import Langchain.Graph.StateGraph (Node (..), NodeId, StateGraph, runGraph)+import Langchain.Graph.StateGraph (CompiledGraph, Node (..), NodeId, runGraph) -- | Construct a special HITL Interrupt LangchainError hitlInterruptError :: NodeId -> LangchainError@@ -64,14 +64,14 @@ -- | Resume an interrupted graph execution after human modification of checkpoint state resumeGraph :: (Checkpointer cp m, FromJSON s, ToJSON s, MonadIO m, MonadError LangchainError m) =>- StateGraph s m ->+ CompiledGraph s m -> cp -> Text -> NodeId -> NodeId -> (s -> s) -> m s-resumeGraph stateGraph cp threadId checkpointNodeId resumeStartNodeId modifier = do+resumeGraph compiledGraph cp threadId checkpointNodeId resumeStartNodeId modifier = do mbState <- loadCheckpoint cp threadId checkpointNodeId case mbState of Left err -> throwError err@@ -84,4 +84,4 @@ Right (Just savedState) -> do let modifiedState = modifier savedState _ <- saveCheckpoint cp threadId resumeStartNodeId modifiedState- runGraph stateGraph resumeStartNodeId modifiedState+ runGraph compiledGraph resumeStartNodeId modifiedState
− src/Langchain/Graph/MultiAgent.hs
@@ -1,96 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Langchain.Graph.MultiAgent-Description : Multi-Agent supervisor routing and sub-graph embedding nodes-Copyright : (c) 2025-2026 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental--Provides multi-agent routing supervisor nodes and sub-graph composition primitives.--}-module Langchain.Graph.MultiAgent- ( supervisorNode- , embedSubGraphNode- , embedSubGraphNodeWithStart- ) where--import Control.Applicative ((<|>))-import Control.Monad.Except (MonadError, throwError)-import Control.Monad.IO.Class (MonadIO)-import Data.Text (Text)-import qualified Data.Text as T--import Langchain.Core.Error (LangchainError, agentError)-import Langchain.Core.Model (ChatModel (..), extractMessageText, userMessage)-import Langchain.Graph.StateGraph (Node (..), NodeId, StateGraph, runGraph, startNodeId)---- | Construct a supervisor routing node that uses an LLM to select the target sub-agent NodeId-supervisorNode ::- (ChatModel model, MonadIO m, MonadError LangchainError m) =>- model ->- NodeId ->- [(Text, NodeId)] ->- (s -> Text) ->- (Text -> s -> s) ->- Node s m-supervisorNode model name routes extractPrompt updateState =- Node- { nodeId = name- , nodeAction = \state -> do- let prompt =- "You are a supervisor delegating tasks to sub-agents. Available routes:\n"- <> T.unlines [rName <> " -> " <> targetId | (rName, targetId) <- routes]- <> "\nTask context: "- <> extractPrompt state- <> "\nReply with ONLY the route name to execute."- msg <- invoke model [userMessage prompt] Nothing- let rawRoute = T.strip (extractMessageText msg)- cleanRoute = T.filter (\c -> c /= '.' && c /= '"' && c /= '\'' && c /= '`') (T.toLower rawRoute)- matchExact =- lookup rawRoute routes- <|> lookup (T.toLower rawRoute) [(T.toLower r, target) | (r, target) <- routes]- <|> lookup- cleanRoute- [(T.toLower (T.filter (\c -> c /= '.' && c /= '"' && c /= '\'') r), target) | (r, target) <- routes]- matchSubstring =- case filter (\(r, _) -> T.toLower r `T.isInfixOf` cleanRoute) routes of- ((_, target) : _) -> Just target- [] -> Nothing- case matchExact <|> matchSubstring of- Just targetId -> pure $ Right $ updateState targetId state- Nothing -> case routes of- ((_, fallbackId) : _) -> pure $ Right $ updateState fallbackId state- [] ->- throwError $ agentError "No routes configured in supervisor node" (Just "supervisorNode") Nothing- }---- | Embed a compiled sub-graph into a parent graph node starting at a specific entry node-embedSubGraphNodeWithStart ::- (MonadIO m, MonadError LangchainError m) =>- NodeId ->- NodeId ->- StateGraph subState m ->- (parentState -> subState) ->- (parentState -> subState -> parentState) ->- Node parentState m-embedSubGraphNodeWithStart name entryId subGraph toSubState mergeState =- Node- { nodeId = name- , nodeAction = \parentSt -> do- let initSubSt = toSubState parentSt- finalSubSt <- runGraph subGraph entryId initSubSt- pure $ Right $ mergeState parentSt finalSubSt- }---- | Embed a compiled sub-graph into a parent graph node starting at 'startNodeId'-embedSubGraphNode ::- (MonadIO m, MonadError LangchainError m) =>- NodeId ->- StateGraph subState m ->- (parentState -> subState) ->- (parentState -> subState -> parentState) ->- Node parentState m-embedSubGraphNode name = embedSubGraphNodeWithStart name startNodeId
− src/Langchain/Graph/Parallel.hs
@@ -1,52 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}--{- |-Module : Langchain.Graph.Parallel-Description : Parallel concurrent node execution with state merging-Copyright : (c) 2025-2026 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental--Executes multiple independent graph nodes concurrently in parallel threads using async,-and merges their resulting sub-states into the parent state via a deterministic reducer.--}-module Langchain.Graph.Parallel- ( parallelNode- , addParallelNodes- ) where--import Control.Concurrent.Async (mapConcurrently)-import Control.Monad.IO.Class (MonadIO, liftIO)--import Langchain.Core.Error (LangchainError)-import Langchain.Graph.StateGraph---- | Construct a parallel composite node that executes worker actions concurrently-parallelNode ::- MonadIO m =>- NodeId ->- [s -> IO (Either LangchainError s)] ->- (s -> [s] -> s) ->- Node s m-parallelNode name workerActions mergeFn =- Node- { nodeId = name- , nodeAction = \initState -> liftIO $ do- results <- mapConcurrently (\action -> action initState) workerActions- case sequence results of- Left err -> pure $ Left err- Right states -> pure $ Right $ mergeFn initState states- }---- | Helper to register a parallel execution step into a StateGraph-addParallelNodes ::- MonadIO m =>- NodeId ->- [s -> IO (Either LangchainError s)] ->- (s -> [s] -> s) ->- StateGraph s m ->- StateGraph s m-addParallelNodes name workerActions mergeFn graph =- let pNode = parallelNode name workerActions mergeFn- in addNode name (nodeAction pNode) graph
src/Langchain/Graph/StateGraph.hs view
@@ -1,93 +1,273 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-} {- | Module : Langchain.Graph.StateGraph-Description : Core StateGraph engine and pure state reducer combinators+Description : Production StateGraph engine with Runnable & LLM node combinators Copyright : (c) 2025-2026 Tushar Adhatrao License : MIT Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com> Stability : experimental -Provides explicit, inspectable, stateful agent graph workflow primitives following graph laws.+Production-grade stateful agent orchestration engine. Supports:+ * First-class LLM ChatModel nodes ('addModelNode', 'addChatNode')+ * First-class Runnable & RunnableTree AST nodes ('addRunnableNode', 'addRunnableTreeNode')+ * Pure state transformation nodes ('addTransformNode')+ * Hierarchical sub-graph embedding ('addSubGraphNode')+ * Static and dynamic conditional edge transitions+ * Recursion limits to prevent runaway loops in cyclic graphs+ * Configurable execution tracing and step logging -} module Langchain.Graph.StateGraph- ( NodeId+ ( -- * Core Types+ NodeId , startNodeId , endNodeId , StateReducer , Node (..) , Edge (..)+ , GraphConfig (..)+ , defaultGraphConfig , StateGraph (..)+ , CompiledGraph (..)++ -- * Graph Construction , emptyStateGraph- , addNode+ , setRecursionLimit+ , setStepLogger , addEdge , addConditionalEdge++ -- * Node Combinators+ , addNode+ , addTransformNode+ , addRunnableNode+ , addRunnableTreeNode+ , addModelNode+ , addModelNodeWithConfig+ , addChatNode+ , addChatNodeWithConfig+ , addSubGraphNode++ -- * Compilation & Execution , compileGraph , runGraph+ , runGraphWithConfig++ -- * Pure State Reducers , appendMessagesReducer , replaceFieldReducer ) where -import Control.Monad.Except (MonadError, throwError)-import Control.Monad.IO.Class (MonadIO)+import Control.Monad (when)+import Control.Monad.Except (ExceptT, MonadError, runExceptT, throwError)+import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Text (Text)+import qualified Data.Text as T -import Langchain.Core.Error (LangchainError, internalError)-import Langchain.Core.Model (Message)+import Langchain.Error (LangchainError, agentError, internalError)+import Langchain.Model (ChatModel, Message, ModelConfig, extractMessageText, userMessage)+import qualified Langchain.Model as M (invoke)+import Langchain.Runnable (Runnable (..), RunnableTree, interpret) -- | Unique node identifier in a StateGraph type NodeId = Text --- | Reserved start node constant+-- | Reserved start node identifier startNodeId :: NodeId startNodeId = "__start__" --- | Reserved end node constant+-- | Reserved end node identifier endNodeId :: NodeId endNodeId = "__end__" --- | Pure state merge function reducer satisfying associative algebra laws+-- | Pure state merge reducer function type StateReducer s = s -> s -> s -- | Node execution action in a StateGraph data Node s m = Node- { nodeId :: NodeId- , nodeAction :: s -> m (Either LangchainError s)+ { nodeId :: !NodeId+ , nodeAction :: !(s -> m (Either LangchainError s)) } instance Show (Node s m) where show n = "Node { nodeId = " ++ show (nodeId n) ++ " }" --- | Edge representation for transition between graph nodes+-- | Edge transition between graph nodes data Edge s m- = StaticEdge NodeId- | ConditionalEdge (s -> m (Either LangchainError NodeId))+ = StaticEdge !NodeId+ | ConditionalEdge !(s -> m (Either LangchainError NodeId)) +-- | Execution configuration for a StateGraph+data GraphConfig = GraphConfig+ { recursionLimit :: !Int+ , logStepTransitions :: !Bool+ , stepLogger :: !(Text -> IO ())+ }++-- | Default configuration with 25-step recursion limit and disabled logging+defaultGraphConfig :: GraphConfig+defaultGraphConfig =+ GraphConfig+ { recursionLimit = 25+ , logStepTransitions = False+ , stepLogger = const (pure ())+ }+ -- | Declarative StateGraph specification data StateGraph s m = StateGraph- { graphNodes :: Map NodeId (Node s m)- , graphEdges :: Map NodeId (Edge s m)- , graphReducer :: StateReducer s+ { graphNodes :: !(Map NodeId (Node s m))+ , graphEdges :: !(Map NodeId (Edge s m))+ , graphReducer :: !(StateReducer s)+ , graphConfig :: !GraphConfig } --- | Construct an empty StateGraph with a given pure StateReducer+-- | A validated, compiled StateGraph ready for execution+data CompiledGraph s m = CompiledGraph+ { compiledNodes :: !(Map NodeId (Node s m))+ , compiledEdges :: !(Map NodeId (Edge s m))+ , compiledReducer :: !(StateReducer s)+ , compiledConfig :: !GraphConfig+ }++-- | Construct an empty StateGraph with a pure StateReducer emptyStateGraph :: StateReducer s -> StateGraph s m emptyStateGraph reducer = StateGraph { graphNodes = Map.empty , graphEdges = Map.empty , graphReducer = reducer+ , graphConfig = defaultGraphConfig } --- | Add an execution node to the StateGraph+-- | Set the maximum step limit to prevent infinite loops in cyclic graphs+setRecursionLimit :: Int -> StateGraph s m -> StateGraph s m+setRecursionLimit limit g =+ g {graphConfig = (graphConfig g) {recursionLimit = limit}}++-- | Configure a custom step transition logger function+setStepLogger :: (Text -> IO ()) -> StateGraph s m -> StateGraph s m+setStepLogger logger g =+ g {graphConfig = (graphConfig g) {logStepTransitions = True, stepLogger = logger}}++-- | Add a raw execution node to the StateGraph addNode :: NodeId -> (s -> m (Either LangchainError s)) -> StateGraph s m -> StateGraph s m addNode name action g = let n = Node {nodeId = name, nodeAction = action} in g {graphNodes = Map.insert name n (graphNodes g)} +-- | Add a pure synchronous state transformation node (no error handling boilerplate)+addTransformNode :: Monad m => NodeId -> (s -> s) -> StateGraph s m -> StateGraph s m+addTransformNode name f =+ addNode name (pure . Right . f)++-- | Add a node backed by any Langchain 'Runnable' (e.g. Tools, ModelRunnable)+addRunnableNode ::+ (Runnable r m, Monad m) =>+ NodeId ->+ r ->+ (s -> RunnableInput r) ->+ (s -> RunnableOutput r -> s) ->+ StateGraph s m ->+ StateGraph s m+addRunnableNode name runnable toInput mergeOutput =+ addNode name $ \state -> do+ eRes <- invoke runnable (toInput state)+ pure $ fmap (mergeOutput state) eRes++-- | Add a node backed by an algebraic 'RunnableTree' pipeline AST+addRunnableTreeNode ::+ (MonadIO m) =>+ NodeId ->+ RunnableTree (ExceptT LangchainError IO) i o ->+ (s -> i) ->+ (s -> o -> s) ->+ StateGraph s m ->+ StateGraph s m+addRunnableTreeNode name tree toInput mergeOutput =+ addNode name $ \state -> liftIO $ do+ eRes <- runExceptT $ interpret tree (toInput state)+ pure $ fmap (mergeOutput state) eRes++-- | Add a dedicated ChatModel node taking and producing rich 'Message' lists+addModelNode ::+ (ChatModel c, MonadIO m) =>+ NodeId ->+ c ->+ (s -> [Message]) ->+ (s -> Message -> s) ->+ StateGraph s m ->+ StateGraph s m+addModelNode name chatModel toMessages mergeMessage =+ addNode name $ \state -> do+ eMsg <- liftIO $ runExceptT $ M.invoke chatModel (toMessages state) Nothing+ pure $ fmap (mergeMessage state) eMsg++-- | Add a dedicated ChatModel node with explicit model configuration+addModelNodeWithConfig ::+ (ChatModel c, MonadIO m) =>+ NodeId ->+ c ->+ ModelConfig c ->+ (s -> [Message]) ->+ (s -> Message -> s) ->+ StateGraph s m ->+ StateGraph s m+addModelNodeWithConfig name chatModel cfg toMessages mergeMessage =+ addNode name $ \state -> do+ eMsg <- liftIO $ runExceptT $ M.invoke chatModel (toMessages state) (Just cfg)+ pure $ fmap (mergeMessage state) eMsg++-- | Add a simple text-in, text-out ChatModel node+addChatNode ::+ (ChatModel c, MonadIO m) =>+ NodeId ->+ c ->+ (s -> Text) ->+ (s -> Text -> s) ->+ StateGraph s m ->+ StateGraph s m+addChatNode name chatModel toPrompt mergeText =+ addNode name $ \state -> do+ let msgs = [userMessage (toPrompt state)]+ eMsg <- liftIO $ runExceptT $ M.invoke chatModel msgs Nothing+ pure $ fmap (mergeText state . extractMessageText) eMsg++-- | Add a simple text-in, text-out ChatModel node with explicit model configuration+addChatNodeWithConfig ::+ (ChatModel c, MonadIO m) =>+ NodeId ->+ c ->+ ModelConfig c ->+ (s -> Text) ->+ (s -> Text -> s) ->+ StateGraph s m ->+ StateGraph s m+addChatNodeWithConfig name chatModel cfg toPrompt mergeText =+ addNode name $ \state -> do+ let msgs = [userMessage (toPrompt state)]+ eMsg <- liftIO $ runExceptT $ M.invoke chatModel msgs (Just cfg)+ pure $ fmap (mergeText state . extractMessageText) eMsg++-- | Embed a compiled sub-graph as a single node in a parent StateGraph+addSubGraphNode ::+ (MonadIO m, MonadError LangchainError m) =>+ NodeId ->+ NodeId ->+ CompiledGraph subState m ->+ (s -> subState) ->+ (s -> subState -> s) ->+ StateGraph s m ->+ StateGraph s m+addSubGraphNode name entryNode subGraph toSubState mergeSubState =+ addNode name $ \parentState -> do+ let initialSub = toSubState parentState+ finalSub <- runGraph subGraph entryNode initialSub+ pure $ Right $ mergeSubState parentState finalSub+ -- | Add a static transition edge between two nodes addEdge :: NodeId -> NodeId -> StateGraph s m -> StateGraph s m addEdge fromNode toNode g =@@ -95,61 +275,115 @@ -- | Add a dynamic conditional transition edge addConditionalEdge ::- NodeId -> (s -> m (Either LangchainError NodeId)) -> StateGraph s m -> StateGraph s m+ NodeId ->+ (s -> m (Either LangchainError NodeId)) ->+ StateGraph s m ->+ StateGraph s m addConditionalEdge fromNode condFn g = g {graphEdges = Map.insert fromNode (ConditionalEdge condFn) (graphEdges g)} --- | Validate state graph invariants. Returns the validated StateGraph or an error.-compileGraph :: StateGraph s m -> Either LangchainError (StateGraph s m)-compileGraph sg =- if Map.null (graphNodes sg)- then Left $ internalError "StateGraph must contain at least one node" (Just "compileGraph") Nothing- else Right sg+-- | Validate state graph invariants and compile to an executable 'CompiledGraph'+compileGraph :: StateGraph s m -> Either LangchainError (CompiledGraph s m)+compileGraph StateGraph {..}+ | Map.null graphNodes =+ Left $ internalError "StateGraph must contain at least one node" (Just "compileGraph") Nothing+ | otherwise =+ Right+ CompiledGraph+ { compiledNodes = graphNodes+ , compiledEdges = graphEdges+ , compiledReducer = graphReducer+ , compiledConfig = graphConfig+ } --- | Execute a StateGraph from start node or specified currentId to endNodeId+-- | Execute a CompiledGraph starting from the given entry NodeId runGraph :: (MonadIO m, MonadError LangchainError m) =>- StateGraph s m ->+ CompiledGraph s m -> NodeId -> s -> m s-runGraph sg@StateGraph {..} currentId state- | currentId == endNodeId = pure state- | otherwise = case Map.lookup currentId graphNodes of- Just node -> do- eNextState <- nodeAction node state- case eNextState of- Left err -> throwError err- Right stepState -> do- let mergedState = graphReducer state stepState- case Map.lookup currentId graphEdges of- Nothing -> pure mergedState- Just (StaticEdge nextId) -> runGraph sg nextId mergedState- Just (ConditionalEdge cond) -> do- eNextId <- cond mergedState- case eNextId of- Left err -> throwError err- Right nextId -> runGraph sg nextId mergedState- Nothing -> case Map.lookup currentId graphEdges of- Just (StaticEdge nextId) -> runGraph sg nextId state- Just (ConditionalEdge cond) -> do- eNextId <- cond state- case eNextId of- Left err -> throwError err- Right nextId -> runGraph sg nextId state- Nothing ->- throwError $- internalError ("Node not found in compiled graph: " <> currentId) (Just currentId) Nothing+runGraph cg = runGraphWithConfig (compiledConfig cg) cg -{- | Standard pure reducer concatenating 'Message' lists.+-- | Execute a CompiledGraph with an explicit configuration override+runGraphWithConfig ::+ (MonadIO m, MonadError LangchainError m) =>+ GraphConfig ->+ CompiledGraph s m ->+ NodeId ->+ s ->+ m s+runGraphWithConfig cfg cg =+ executeStep 0+ where+ CompiledGraph {..} = cg+ GraphConfig {..} = cfg -__Note:__ This reducer expects node actions to return only the /new/ messages (the delta)-to append to the existing history. If your node actions return the full accumulated message-list, use 'replaceFieldReducer' instead to avoid duplicating previous messages.--}+ logMsg msg =+ when logStepTransitions $ liftIO $ stepLogger msg++ executeStep stepCount currentId state+ | stepCount >= recursionLimit = do+ let errMsg =+ "StateGraph recursion limit exceeded (max "+ <> T.pack (show recursionLimit)+ <> " steps at node: "+ <> currentId+ <> ")"+ logMsg $ "[StateGraph ERROR] " <> errMsg+ throwError $ agentError errMsg (Just "runGraph") (Just currentId)+ | currentId == endNodeId = do+ logMsg "[StateGraph] Reached terminal node (__end__)"+ pure state+ | otherwise = case Map.lookup currentId compiledNodes of+ Just node -> do+ logMsg $+ "[StateGraph] Step " <> T.pack (show (stepCount + 1)) <> ": Entering node '" <> currentId <> "'"+ eNextState <- nodeAction node state+ case eNextState of+ Left err -> do+ logMsg $ "[StateGraph ERROR] Node '" <> currentId <> "' failed: " <> T.pack (show err)+ throwError err+ Right stepState -> do+ let mergedState = compiledReducer state stepState+ logMsg $ "[StateGraph] Node '" <> currentId <> "' executed successfully"+ case Map.lookup currentId compiledEdges of+ Nothing -> do+ logMsg $ "[StateGraph] No outgoing edge from '" <> currentId <> "', terminating"+ pure mergedState+ Just (StaticEdge nextId) -> do+ logMsg $ "[StateGraph] Static edge: '" <> currentId <> "' -> '" <> nextId <> "'"+ executeStep (stepCount + 1) nextId mergedState+ Just (ConditionalEdge cond) -> do+ eNextId <- cond mergedState+ case eNextId of+ Left err -> do+ logMsg $+ "[StateGraph ERROR] Conditional edge from '" <> currentId <> "' failed: " <> T.pack (show err)+ throwError err+ Right nextId -> do+ logMsg $ "[StateGraph] Conditional edge: '" <> currentId <> "' -> '" <> nextId <> "'"+ executeStep (stepCount + 1) nextId mergedState+ Nothing -> case Map.lookup currentId compiledEdges of+ Just (StaticEdge nextId) -> do+ logMsg $ "[StateGraph] Passthrough edge: '" <> currentId <> "' -> '" <> nextId <> "'"+ executeStep (stepCount + 1) nextId state+ Just (ConditionalEdge cond) -> do+ eNextId <- cond state+ case eNextId of+ Left err -> throwError err+ Right nextId -> do+ logMsg $ "[StateGraph] Passthrough conditional edge: '" <> currentId <> "' -> '" <> nextId <> "'"+ executeStep (stepCount + 1) nextId state+ Nothing -> do+ let errText = "Node not found in compiled graph: " <> currentId+ logMsg $ "[StateGraph ERROR] " <> errText+ throwError $ internalError errText (Just currentId) Nothing++-- | Standard pure reducer concatenating Message lists appendMessagesReducer :: StateReducer [Message] appendMessagesReducer old new = old ++ new --- | Standard pure reducer replacing previous state field with new state+-- | Standard pure reducer replacing previous state with the newly returned state replaceFieldReducer :: StateReducer a replaceFieldReducer _ new = new
test/Spec.hs view
@@ -4,7 +4,6 @@ import qualified Test.Langchain.Graph.CheckpointerSpec as CheckpointerSpec import qualified Test.Langchain.Graph.HITLSpec as HITLSpec-import qualified Test.Langchain.Graph.MultiAgentSpec as MultiAgentSpec import qualified Test.Langchain.Graph.StateGraphSpec as StateGraphSpec main :: IO ()@@ -17,5 +16,4 @@ [ StateGraphSpec.tests , CheckpointerSpec.tests , HITLSpec.tests- , MultiAgentSpec.tests ]
test/Test/Langchain/Graph/HITLSpec.hs view
@@ -4,7 +4,7 @@ import Control.Monad.Except (ExceptT, runExceptT) import Data.Text (Text)-import Langchain.Core.Error (LangchainError)+import Langchain.Error (LangchainError) import Langchain.Graph.Checkpointer import Langchain.Graph.HITL import Langchain.Graph.StateGraph
− test/Test/Langchain/Graph/MultiAgentSpec.hs
@@ -1,53 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Test.Langchain.Graph.MultiAgentSpec (tests) where--import Control.Monad.Except (ExceptT, runExceptT)-import Data.Text (Text)-import Langchain.Core.Error (LangchainError)-import Langchain.Core.Model (ChatModel (..), assistantMessage)-import Langchain.Graph.MultiAgent-import Langchain.Graph.StateGraph-import Test.Tasty-import Test.Tasty.HUnit--newtype MockModel = MockModel Text--instance ChatModel MockModel where- type ModelConfig MockModel = ()- invoke (MockModel resp) _ _ = pure $ assistantMessage resp- stream = undefined--newMockModel :: Text -> MockModel-newMockModel = MockModel--tests :: TestTree-tests =- testGroup- "Langchain.Graph.MultiAgentSpec"- [ testCase "supervisorNode routes based on mock LLM response" $ do- let mockModel = newMockModel "researcher"- node =- supervisorNode- mockModel- "supervisor"- [("researcher", "researchNode")]- id- (\target s -> s <> ":" <> target)- res <- runExceptT $ nodeAction node ("task-input" :: Text)- res @?= Right (Right ("task-input:researchNode" :: Text))- , testCase "embedSubGraphNode executes nested graph" $ do- let action :: Text -> ExceptT LangchainError IO (Either LangchainError Text)- action s = pure $ Right (s <> " [sub-processed]")- subG =- addEdge startNodeId "sub1" $- addEdge "sub1" endNodeId $- addNode "sub1" action $- emptyStateGraph replaceFieldReducer- case compileGraph subG of- Left err -> assertFailure $ "Failed to compile sub-graph: " ++ show err- Right compiledSub -> do- let parentN = embedSubGraphNode "subGraphNode" compiledSub id (\p s -> p <> " | " <> s)- res <- runExceptT $ nodeAction parentN ("parent-input" :: Text)- res @?= Right (Right ("parent-input | parent-input [sub-processed]" :: Text))- ]
test/Test/Langchain/Graph/StateGraphSpec.hs view
@@ -2,26 +2,20 @@ module Test.Langchain.Graph.StateGraphSpec (tests) where +import Control.Concurrent.STM (atomically, modifyTVar', newTVarIO, readTVarIO) import Control.Monad.Except (ExceptT, runExceptT) import Data.Text (Text)-import Langchain.Core.Error (LangchainError)+import qualified Data.Text as T+import Langchain.Error (LangchainError, errorMessage) import Langchain.Graph.StateGraph+import Langchain.Runnable (RunnableTree (..), runPure, (|>>)) import Test.Tasty import Test.Tasty.HUnit-import Test.Tasty.QuickCheck tests :: TestTree tests = testGroup "Langchain.Graph.StateGraphSpec"- [ unitTests- , propertyTests- ]--unitTests :: TestTree-unitTests =- testGroup- "Unit Tests" [ testCase "compileGraph succeeds on valid node graph" $ do let action :: Text -> ExceptT LangchainError IO (Either LangchainError Text) action s = pure $ Right (s <> " world")@@ -41,16 +35,77 @@ Right compiled -> do res <- runExceptT $ runGraph compiled "node1" ("hello" :: Text) res @?= Right "hello world"- ]--propertyTests :: TestTree-propertyTests =- testGroup- "Property Tests (Laws)"- [ testProperty "Reducer Associativity Law: (a <> b) <> c == a <> (b <> c)" $ \a b c ->- let s1 = (a :: String) ++ (b :: String)- s2 = s1 ++ (c :: String)- s3 = b ++ c- s4 = a ++ s3- in s2 == s4+ , testCase "addTransformNode updates state purely" $ do+ let g =+ addEdge "step1" endNodeId $+ addTransformNode "step1" (<> " [transformed]") $+ emptyStateGraph replaceFieldReducer+ case compileGraph g of+ Left err -> assertFailure $ "Compilation failed: " ++ show err+ Right compiled -> do+ res <- runExceptT $ runGraph compiled "step1" ("input" :: Text)+ res @?= Right "input [transformed]"+ , testCase "addRunnableTreeNode executes pipeline AST within graph node" $ do+ let pipeline :: RunnableTree (ExceptT LangchainError IO) Text Text+ pipeline = runPure (<> " -> p1") |>> runPure (<> " -> p2")+ g =+ addEdge "runnableNode" endNodeId $+ addRunnableTreeNode "runnableNode" pipeline id (\_ out -> out) $+ emptyStateGraph replaceFieldReducer+ case compileGraph g of+ Left err -> assertFailure $ "Compilation failed: " ++ show err+ Right compiled -> do+ res <- runExceptT $ runGraph compiled "runnableNode" ("start" :: Text)+ res @?= Right "start -> p1 -> p2"+ , testCase "recursionLimit terminates cyclic graph with error" $ do+ let g =+ setRecursionLimit 5 $+ addEdge "loop" "loop" $+ addTransformNode "loop" (\n -> (n + 1 :: Int)) $+ emptyStateGraph replaceFieldReducer+ case compileGraph g of+ Left err -> assertFailure $ "Compilation failed: " ++ show err+ Right compiled -> do+ res <- runExceptT $ runGraph compiled "loop" (0 :: Int)+ case res of+ Left err ->+ assertBool+ "Contains recursion limit message"+ ("recursion limit exceeded" `T.isInfixOf` errorMessage err)+ Right val -> assertFailure $ "Expected recursion error, but got: " ++ show val+ , testCase "addSubGraphNode embeds and executes sub-graph cleanly" $ do+ let subG =+ addEdge "sub1" endNodeId $+ addTransformNode "sub1" (<> "_sub") $+ emptyStateGraph replaceFieldReducer+ case compileGraph subG of+ Left err -> assertFailure $ "Sub-graph compile failed: " ++ show err+ Right compiledSub -> do+ let parentG =+ addEdge "parent1" endNodeId $+ addSubGraphNode "parent1" "sub1" compiledSub id (\_ subOut -> "parent:" <> subOut) $+ emptyStateGraph replaceFieldReducer+ case compileGraph parentG of+ Left err -> assertFailure $ "Parent compile failed: " ++ show err+ Right compiledParent -> do+ res <- runExceptT $ runGraph compiledParent "parent1" ("init" :: Text)+ res @?= Right "parent:init_sub"+ , testCase "setStepLogger logs transition messages" $ do+ logsVar <- newTVarIO []+ let logger msg = atomically $ modifyTVar' logsVar (msg :)+ g =+ setStepLogger logger $+ addEdge "a" "b" $+ addEdge "b" endNodeId $+ addTransformNode "a" (<> "_A") $+ addTransformNode "b" (<> "_B") $+ emptyStateGraph replaceFieldReducer+ case compileGraph g of+ Left err -> assertFailure $ "Compile failed: " ++ show err+ Right compiled -> do+ res <- runExceptT $ runGraph compiled "a" ("start" :: Text)+ res @?= Right "start_A_B"+ logs <- readTVarIO logsVar+ assertBool "Recorded log messages" (length logs >= 4)+ assertBool "Logged entering node 'a'" (any ("Entering node 'a'" `T.isInfixOf`) logs) ]