langchain-hs-0.0.3.0: src/Langchain/Runnable/Utils.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}
{- |
Module : Langchain.Runnable.Utils
Description : Utility wrappers for Runnable components in LangChain
Copyright : (c) 2025 Tushar Adhatrao
License : MIT
Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>
This module provides various utility wrappers for 'Runnable' components that enhance
their behavior with common patterns like:
* Configuration management
* Result caching
* Automatic retries
* Timeout handling
These utilities follow the decorator pattern, wrapping existing 'Runnable' instances
with additional functionality while preserving the original input/output types.
Note: This module is experimental and the API may change in future versions.
-}
module Langchain.Runnable.Utils
( -- * Configuration Management
WithConfig (..)
-- * Caching
, Cached (..)
, cached
-- * Resilience Patterns
, Retry (..)
, WithTimeout (..)
) where
import Control.Concurrent
import Data.Map.Strict as Map
import Langchain.Error (llmError)
import Langchain.Runnable.Core
{- | Wrapper for 'Runnable' components with configurable behavior.
This wrapper allows attaching configuration data to a 'Runnable' instance.
The configuration data can be accessed and modified without changing the
underlying 'Runnable' implementation.
Example:
@
data LLMConfig = LLMConfig
{ temperature :: Float
, maxTokens :: Int
}
let
baseModel = OpenAI defaultOpenAIConfig
configuredModel = WithConfig
{ configuredRunnable = baseModel
, runnableConfig = LLMConfig 0.7 100
}
-- Later, modify the configuration without changing the model
let updatedModel = configuredModel { runnableConfig = LLMConfig 0.9 150 }
-- Use the model as a regular Runnable
result <- invoke updatedModel "Explain monads in Haskell"
@
-}
data WithConfig config r
= (Runnable r) =>
WithConfig
{ configuredRunnable :: r
-- ^ The wrapped 'Runnable' instance
, runnableConfig :: config
-- ^ Configuration data for this 'Runnable'
}
-- | Make WithConfig a Runnable that applies the configuration
instance (Runnable r) => Runnable (WithConfig config r) where
type RunnableInput (WithConfig config r) = RunnableInput r
type RunnableOutput (WithConfig config r) = RunnableOutput r
invoke (WithConfig r1 _) = invoke r1
{- | Cache results of a 'Runnable' to avoid duplicate computations.
This wrapper stores previously computed results in a thread-safe cache.
When an input is encountered again, the cached result is returned instead
of recomputing it, which can significantly improve performance for expensive
operations or when the same inputs are frequently processed.
Note: The cached results are stored in-memory and will be lost when the program
terminates. For persistent caching, consider implementing a custom wrapper that
uses database storage.
The 'RunnableInput' type must be an instance of 'Ord' for map lookups.
-}
data Cached r
= (Runnable r, Ord (RunnableInput r)) =>
Cached
{ cachedRunnable :: r
-- ^ The wrapped 'Runnable' instance
, cacheMap :: MVar (Map.Map (RunnableInput r) (RunnableOutput r))
-- ^ Thread-safe cache storage
}
cached :: (Runnable r, Ord (RunnableInput r)) => r -> IO (Cached r)
cached r = do
cache <- newMVar Map.empty
return $ Cached r cache
-- | Make Cached a Runnable that uses a cache
instance (Runnable r, Ord (RunnableInput r)) => Runnable (Cached r) where
type RunnableInput (Cached r) = RunnableInput r
type RunnableOutput (Cached r) = RunnableOutput r
invoke (Cached r cacheRef) input = do
cache <- readMVar cacheRef
case Map.lookup input cache of
Just output -> return $ Right output -- Cache hit: return cached result
Nothing -> do
-- Cache miss: compute and store resul
result <- invoke r input
case result of
Left err -> return $ Left err
Right output -> do
modifyMVar_ cacheRef $ \c -> return $ Map.insert input output c
return $ Right output
{- | Add retry capability to any 'Runnable'.
This wrapper automatically retries failed operations up to a specified
number of times with a configurable delay between attempts. This is particularly
useful for network operations or external API calls that might fail transiently.
Example:
@
-- Create an LLM with automatic retry for network failures
let
baseModel = OpenAI defaultConfig
resilientModel = Retry
{ retryRunnable = baseModel
, maxRetries = 3
, retryDelay = 1000000 -- 1 second delay between retries
}
-- If the API call fails, it will retry up to 3 times
result <- invoke resilientModel "Generate a story about a Haskell programmer"
@
-}
data Retry r
= (Runnable r) =>
Retry
{ retryRunnable :: r
-- ^ The wrapped 'Runnable' instance
, maxRetries :: Int
-- ^ Maximum number of retry attempts
, retryDelay :: Int
-- ^ Delay between retry attempts in microseconds
}
-- | Make Retry a Runnable that retries on failure
instance (Runnable r) => Runnable (Retry r) where
type RunnableInput (Retry r) = RunnableInput r
type RunnableOutput (Retry r) = RunnableOutput r
invoke (Retry r maxRetries_ delay) input = retryWithCount 0
where
retryWithCount count = do
result <- invoke r input
case result of
Left err ->
if count < maxRetries_
then do
threadDelay delay
retryWithCount (count + 1)
else return $ Left err
Right output -> return $ Right output
{- | Add timeout capability to any 'Runnable'.
This wrapper enforces a maximum execution time for the wrapped 'Runnable'.
If the operation takes longer than the specified timeout, it is cancelled and
an error is returned. This is useful for limiting the execution time of potentially
long-running operations.
Example:
@
-- Create an LLM with a 30-second timeout
let
baseModel = OpenAI defaultConfig
timeboxedModel = WithTimeout
{ timeoutRunnable = baseModel
, timeoutMicroseconds = 30000000 -- 30 seconds
}
-- If the API call takes longer than 30 seconds, it will be cancelled
result <- invoke timeboxedModel "Generate a detailed analysis of Haskell's type system"
@
Note: This implementation uses 'forkIO' and 'killThread', which may not always
cleanly terminate the underlying operation, especially for certain types of I/O.
For critical applications, consider implementing a more robust timeout mechanism.
-}
data WithTimeout r
= (Runnable r) =>
WithTimeout
{ timeoutRunnable :: r
-- ^ The wrapped 'Runnable' instance
, timeoutMicroseconds :: Int
-- ^ Timeout duration in microseconds
}
-- | Make WithTimeout a Runnable that times out
instance (Runnable r) => Runnable (WithTimeout r) where
type RunnableInput (WithTimeout r) = RunnableInput r
type RunnableOutput (WithTimeout r) = RunnableOutput r
invoke (WithTimeout r timeout) input = do
resultVar <- newEmptyMVar
-- Fork a thread to run the computation
tid <- forkIO $ do
result <- invoke r input
putMVar resultVar (Just result)
-- Set up the timeout
timeoutTid <- forkIO $ do
threadDelay timeout
putMVar resultVar Nothing
-- Wait for either result or timeout
result <- takeMVar resultVar
-- Kill the other thread
killThread tid
killThread timeoutTid
case result of
Just r_ -> return r_
Nothing -> return $ Left (llmError "Operation timed out" Nothing Nothing)