packages feed

langchain-hs-core-0.0.6.0: src/Langchain/Memory/Summary.hs

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

{- |
Module      : Langchain.Memory.Summary
Description : Summary-based conversation memory with progressive LLM summarization
Copyright   : (c) 2025-2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

Progressively summarizes older conversation history using a ChatModel when history exceeds a threshold.
Guarantees transaction safety: conversation history is only pruned after the LLM summarization
call successfully completes, preventing irreversible data loss on network or model failures.
-}
module Langchain.Memory.Summary
  ( SummaryMemory (..)
  , newSummaryMemory
  , newSummaryMemoryWithPrompt
  , getSummary
  , defaultSummaryPrompt
  , formatMessages
  ) where

import Control.Concurrent.STM
  ( TVar
  , atomically
  , modifyTVar'
  , newTVarIO
  , readTVar
  , readTVarIO
  , writeTVar
  )
import Control.Monad (when)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.List (partition)
import Data.Text (Text)
import qualified Data.Text as T

import Langchain.Memory.Core (BaseMemory (..), formatHistory)
import Langchain.Model
  ( ChatModel (..)
  , Message (..)
  , Role (..)
  , extractMessageText
  , systemMessage
  , userMessage
  )

-- | Progressive summarization memory backed by thread-safe STM TVars
data SummaryMemory model = SummaryMemory
  { summaryModel :: model
  , maxMessageThreshold :: !Int
  , summaryBufferVar :: !(TVar Text)
  , recentMessagesVar :: !(TVar [Message])
  , promptBuilder :: !(Text -> [Message] -> Text)
  , initialMessagesList :: ![Message]
  }

-- | Default summarization prompt generator
defaultSummaryPrompt :: Text -> [Message] -> Text
defaultSummaryPrompt currentSummary toSummarize =
  "Current summary:\n"
    <> currentSummary
    <> "\n\nNew lines to summarize:\n"
    <> formatMessages toSummarize
    <> "\n\nPlease provide an updated, concise summary of the conversation above."

-- | Construct a new SummaryMemory instance with default prompt
newSummaryMemory :: MonadIO m => model -> Int -> [Message] -> m (SummaryMemory model)
newSummaryMemory model threshold initMsgs =
  newSummaryMemoryWithPrompt model threshold initMsgs defaultSummaryPrompt

-- | Construct a SummaryMemory instance with custom summarization prompt generator
newSummaryMemoryWithPrompt ::
  MonadIO m =>
  model ->
  Int ->
  [Message] ->
  (Text -> [Message] -> Text) ->
  m (SummaryMemory model)
newSummaryMemoryWithPrompt model threshold initMsgs pBuilder = liftIO $ do
  sVar <- newTVarIO ""
  mVar <- newTVarIO initMsgs
  pure $ SummaryMemory model threshold sVar mVar pBuilder initMsgs

-- | Retrieve the current accumulated summary text
getSummary :: MonadIO m => SummaryMemory model -> m Text
getSummary SummaryMemory {..} = liftIO $ readTVarIO summaryBufferVar

instance (ChatModel model) => BaseMemory (SummaryMemory model) where
  messages SummaryMemory {..} = liftIO $ do
    sumTxt <- readTVarIO summaryBufferVar
    recent <- readTVarIO recentMessagesVar
    if T.null sumTxt
      then pure recent
      else do
        -- Preserve any system instructions at the head, followed by the summary, followed by recent turns
        let (sysMsgs, nonSysMsgs) = partition (\m -> messageRole m == System) recent
            summaryMsg = systemMessage ("Summary of previous conversation:\n" <> sumTxt)
        pure (sysMsgs ++ [summaryMsg] ++ nonSysMsgs)

  addMessage SummaryMemory {..} newMsg = do
    -- 1. Append message to recent buffer
    (shouldSummarize, toSummarize, keepCount) <- liftIO $ atomically $ do
      modifyTVar' recentMessagesVar (\msgs -> msgs ++ [newMsg])
      currentMsgs <- readTVar recentMessagesVar
      if length currentMsgs > maxMessageThreshold
        then do
          let keep = max 2 (maxMessageThreshold `div` 2)
              (old, _) = splitAt (length currentMsgs - keep) currentMsgs
          pure (True, old, keep)
        else pure (False, [], length currentMsgs)

    -- 2. If summarization threshold reached, execute LLM summarization.
    -- Crucial: Pruning ONLY happens after the LLM invoke succeeds!
    when (shouldSummarize && not (null toSummarize)) $ do
      currentSummary <- liftIO $ readTVarIO summaryBufferVar
      let summaryPrompt = promptBuilder currentSummary toSummarize
      aiResp <- invoke summaryModel [userMessage summaryPrompt] Nothing
      let newSummary = extractMessageText aiResp

      -- Atomically commit new summary and prune summarized messages
      liftIO $ atomically $ do
        writeTVar summaryBufferVar newSummary
        modifyTVar' recentMessagesVar (\msgs -> drop (max 0 (length msgs - keepCount)) msgs)

  clear SummaryMemory {..} = liftIO $ atomically $ do
    writeTVar summaryBufferVar ""
    writeTVar recentMessagesVar []

formatMessages :: [Message] -> Text
formatMessages = formatHistory