packages feed

langchain-hs-graph-0.0.5.0: src/Langchain/Graph/HITL.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : Langchain.Graph.HITL
Description : Human-in-the-Loop (HITL) interrupt and state resume mechanisms
Copyright   : (c) 2025-2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

Provides interrupt signal types and resume helpers for human review workflows.
-}
module Langchain.Graph.HITL
  ( hitlInterruptError
  , isHITLInterrupt
  , hitlNode
  , resumeGraph
  ) where

import Control.Monad.Except (MonadError, throwError)
import Control.Monad.IO.Class (MonadIO)
import Data.Aeson (FromJSON, ToJSON)
import Data.Text (Text)
import qualified Data.Text as T

import Langchain.Core.Error (LangchainError (..), agentError, errorMessage)
import Langchain.Graph.Checkpointer (Checkpointer (..))
import Langchain.Graph.StateGraph (Node (..), NodeId, StateGraph, runGraph)

-- | Construct a special HITL Interrupt LangchainError
hitlInterruptError :: NodeId -> LangchainError
hitlInterruptError targetNodeId =
  agentError ("HITL_INTERRUPT:" <> targetNodeId) (Just "HITL") (Just targetNodeId)

-- | Check whether an error is a HITL Interrupt signal and return the NodeId
isHITLInterrupt :: LangchainError -> Maybe NodeId
isHITLInterrupt err =
  let msg = errorMessage err
   in if "HITL_INTERRUPT:" `T.isPrefixOf` msg
        then Just (T.drop (T.length ("HITL_INTERRUPT:" :: Text)) msg)
        else Nothing

-- | Create a Human-in-the-Loop Node that saves a checkpoint and interrupts execution for human review
hitlNode ::
  (Checkpointer cp m, ToJSON s, MonadIO m) =>
  cp ->
  Text ->
  NodeId ->
  (s -> m (Either LangchainError s)) ->
  Node s m
hitlNode cp threadId name preAction =
  Node
    { nodeId = name
    , nodeAction = \state -> do
        eSt <- preAction state
        case eSt of
          Left err -> pure $ Left err
          Right st -> do
            _ <- saveCheckpoint cp threadId name st
            pure $ Left $ hitlInterruptError name
    }

-- | 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 ->
  cp ->
  Text ->
  NodeId ->
  NodeId ->
  (s -> s) ->
  m s
resumeGraph stateGraph cp threadId checkpointNodeId resumeStartNodeId modifier = do
  mbState <- loadCheckpoint cp threadId checkpointNodeId
  case mbState of
    Left err -> throwError err
    Right Nothing ->
      throwError $
        agentError
          ("No checkpoint found to resume at node: " <> checkpointNodeId)
          (Just "resumeGraph")
          Nothing
    Right (Just savedState) -> do
      let modifiedState = modifier savedState
      _ <- saveCheckpoint cp threadId resumeStartNodeId modifiedState
      runGraph stateGraph resumeStartNodeId modifiedState