packages feed

langchain-hs-0.0.1.0: src/Langchain/Agents/React.hs

{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}

{- |
Module      : Langchain.Agents.React
Description : Implementation of ReAct agent combining reasoning and action
Copyright   : (c) 2025 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>

Implements the ReAct pattern where the agent alternates between:

1. Reasoning (generating thoughts)
2. Acting (executing tools)

Example agent interaction:

> agent <- createReactAgent llm [wikipediaTool, calculatorTool]
> result <- runAgentExecutor executor "What's the population of Paris?"
> -- Agent might:
> -- 1. Use Wikipedia tool to find current population data
> -- 2. Use calculator tool to verify numbers
> -- 3. Return final answer
-}
module Langchain.Agents.React
  ( ReactAgentOutputParser (..)
  , parseReactOutput
  , ReactAgent (..)
  , createReactAgent
  , formatToolDescriptions
  , formatToolNames
  , getLastUserInput
  ) where

import qualified Data.List.NonEmpty as NE
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import Langchain.Agents.Core
import Langchain.LLM.Core
import Langchain.Memory.Core
import Langchain.OutputParser.Core
import Langchain.PromptTemplate
import Langchain.Tool.Core

{- |
Output parser for ReAct agent responses
Handles two primary formats:

1. Final answers containing "Final Answer:"
2. Action requests with "Action:" and "Action Input:"

Example parsing:

> parseReactOutput "Final Answer: 42"
> -- Right (Finish ...)
>
> parseReactOutput "Action: calculator\nAction Input: 5+3"
> -- Right (Continue ...)
-}
newtype ReactAgentOutputParser = ReactAgentOutputParser AgentStep

instance OutputParser ReactAgentOutputParser where
  parse = parseReactOutput

-- | Parses the output from a React agent
parseReactOutput :: Text -> Either String ReactAgentOutputParser
parseReactOutput text
  | T.isInfixOf "Final Answer:" text =
      -- Extract the final answer
      let answer = extractAfter "Final Answer:" text
       in Right $
            ReactAgentOutputParser $
              Finish $
                AgentFinish
                  { returnValues = Map.singleton "output" answer
                  , finishLog = text
                  }
  | T.isInfixOf "Action:" text && T.isInfixOf "Action Input:" text =
      -- Extract action and action input
      let actionName = extractAfter "Action:" $ T.takeWhile (/= '\n') $ T.dropWhile (/= 'A') text
          actionInput_ =
            extractAfter "Action Input:" $ T.takeWhile (/= '\n') $ snd $ T.breakOn "Action Input:" text
       in Right $
            ReactAgentOutputParser $
              Continue $
                AgentAction
                  { actionToolName = T.strip actionName
                  , actionInput = T.strip actionInput_
                  , actionLog = text
                  }
  | otherwise = Left $ "Could not parse agent output: " <> T.unpack text

{- |
Core ReAct agent configuration.
Contains:

- LLM for reasoning
- Available tools
- Prompt template for interaction

Example creation:

> agent <- createReactAgent
>   openAIGPT
>   [ AnyTool wikipediaTool
>   , AnyTool calculatorTool
>   ]
-}
data (LLM llm) => ReactAgent llm = ReactAgent
  { reactLLM :: llm
  , reactTools :: [AnyTool]
  , reactPromptTemplate :: PromptTemplate
  }

-- Helper function to extract text after a marker
extractAfter :: Text -> Text -> Text
extractAfter marker text =
  let afterMarker = snd $ T.breakOn marker text
   in if T.null afterMarker
        then ""
        else T.strip $ T.dropWhile (/= ':') afterMarker

{- |
Creates a ReAct agent with standard prompt structure
The prompt instructs the LLM to:

1. List available tools
2. Follow thought-action-observation pattern
3. Provide final answers

Example prompt excerpt:

> "Use the following format:
> Thought: ...
> Action: [tool_name]
> Action Input: ..."
-}
createReactAgent ::
  (LLM llm) =>
  llm ->
  [AnyTool] ->
  IO (Either String (ReactAgent llm))
createReactAgent llm tools = do
  let reactPrompt =
        PromptTemplate $
          T.unlines
            [ "You are an AI assistant designed to help with tasks."
            , "You have access to the following tools:"
            , "{tools_description}"
            , ""
            , "Use the following format:"
            , ""
            , "Thought: you should always think about what to do"
            , "Action: the action to take, should be one of [{tool_names}]"
            , "Action Input: the input to the action"
            , "Observation: the result of the action"
            , "... (this Thought/Action/Action Input/Observation can repeat N times)"
            , "Thought: I now know the final answer"
            , "Final Answer: the final answer to the original input question"
            ]
  return $
    Right $
      ReactAgent
        { reactLLM = llm
        , reactTools = tools
        , reactPromptTemplate = reactPrompt
        }

instance (LLM llm) => Agent (ReactAgent llm) where
  -- \|
  --  Core reasoning loop implementing ReAct pattern
  --
  --  1. Retrieve chat history
  --  2. Format tool information
  --  3. Construct reasoning prompt
  --  4. Execute LLM call
  --  5. Parse response into action/answer
  --
  --  Uses depth-first planning with backtracking
  --
  planNextAction ReactAgent {..} state = do
    let mem = agentMemory state
    msgResult <- messages mem
    case msgResult of
      Left err -> return $ Left err
      Right msgs -> do
        -- Format the tools descriptions
        let toolDescs = formatToolDescriptions reactTools
            userQuery = getLastUserInput msgs
        -- Build the prompt variables
        let promptVars =
              Map.fromList
                [ ("tools_description", toolDescs)
                , ("tool_names", formatToolNames reactTools)
                ]

        -- Render the prompt
        case renderPrompt reactPromptTemplate promptVars of
          Left err -> return $ Left err
          Right renderedPrompt -> do
            -- Call the LLM
            let m =
                  ( msgs
                      `NE.append` NE.fromList
                        [ (Message System renderedPrompt defaultMessageData)
                        , (Message User userQuery defaultMessageData)
                        ]
                  )
            response <-
              chat
                reactLLM
                m
                Nothing
            case response of
              Left err -> return $ Left err
              Right llmOutput -> do
                -- Parse the output
                case parse llmOutput of
                  Left err -> return $ Left $ "Failed to parse LLM output: " <> err
                  Right (ReactAgentOutputParser step) -> return $ Right step

  agentPrompt ReactAgent {..} = pure reactPromptTemplate
  agentTools ReactAgent {..} = pure reactTools

{- |
Formats tool descriptions for LLM consumption
Creates a list like:

> "Tool: wikipedia
>  Description: Search Wikipedia..."
-}
formatToolDescriptions :: [AnyTool] -> Text
formatToolDescriptions tools = T.intercalate "\n\n" $ map formatTool tools
  where
    formatTool (AnyTool tool _ _) =
      T.concat ["Tool: ", toolName tool, "\nDescription: ", toolDescription tool]

{- |
Creates comma-separated tool names for prompt inclusion
Example output: "wikipedia, calculator, weather"
-}
formatToolNames :: [AnyTool] -> Text
formatToolNames tools = T.intercalate ", " $ map (\(AnyTool tool _ _) -> toolName tool) tools

{- |
Extracts latest user query from chat history
Handles cases where:

- Multiple user messages exist
- No user input found
-}
getLastUserInput :: ChatMessage -> Text
getLastUserInput msgs =
  let userMsgs = filter (\m -> role m == User) $ NE.toList msgs
   in if null userMsgs
        then ""
        else content $ last userMsgs