packages feed

langchain-hs-core-0.0.6.2: src/Langchain/Agent/ReAct.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

{- |
Module      : Langchain.Agent.ReAct
Description : Effect-polymorphic ReAct (Reasoning + Acting) Agent
Copyright   : (c) 2025-2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

ReAct (Reasoning and Acting) agent loop that supports:

* __Effect-polymorphic tools__: Tools operate in the agent's monad @m@.
* __Memory integration__: Pluggable 'BaseMemory' via 'withMemory'.
* __Model-specific configs__: Merged with tool definitions via 'withModelConfig'.
* __Observability__: Lifecycle hooks via 'withCallbackManager'.
* __Error resilience__: Configurable 'ToolErrorStrategy'.
* __Iteration limits__: Prevents infinite loops via 'withMaxIterations'.
* __Early stop conditions__: User-defined stopping predicates via 'withStopCondition'.
* __Reasoning traces__: Inspection via 'runReActAgentWithTrace'.

=== Quick Start

@
let tools = [shellTool]
let agent = defaultReActAgent model tools
result <- runReActAgent agent [userMessage "What is 2+2?"]
@

=== With Memory and Tracing

@
mem <- newWindowBufferMemory 10 []
let agent =
      withMemory mem $
        withSystemPrompt "You are a helpful math tutor." $
          defaultReActAgent model tools
trace <- runReActAgentWithTrace agent [userMessage "What is the square root of 144?"]
@
-}
module Langchain.Agent.ReAct
  ( -- * Core Types
    AgentStep (..)
  , AgentTrace (..)
  , ReActAgent (..)
  , ToolErrorStrategy (..)
  , LoopDetectionStrategy (..)

    -- * Construction
  , defaultReActAgent

    -- * Configuration (builder-style)
  , withMemory
  , withoutMemory
  , withSystemPrompt
  , withMaxIterations
  , withModelConfig
  , withCallbackManager
  , withStopCondition
  , withToolErrorStrategy
  , withStreamingTools
  , withLoopDetection
  , withMaxConsecutiveIdenticalToolCalls
  , withMaxDelegationDepth

    -- * Multi-Agent Handoff & Delegation Tools
  , asAgentTool
  , asStreamingAgentTool

    -- * Execution
  , reactStep
  , runReActAgent
  , runReActAgentWithTrace

    -- * Streaming Execution
  , InterruptibleResult (..)
  , reactStepStream
  , streamReActAgent
  , streamReActAgentWithTrace
  , runReActAgentStream
  , runReActAgentStreamInterruptible
  ) where

import Control.Monad (unless, when)
import Control.Monad.Except (MonadError, catchError, throwError)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Trans (lift)
import Data.Aeson (Value (..), object, toJSON, (.=))
import Data.Aeson.Types (parseEither, (.:?))
import Data.Conduit (ConduitT, await, fuseUpstream, mapOutput, runConduit, yield, (.|))
import qualified Data.Conduit.List as CL
import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
import Data.List (find)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (diffUTCTime, getCurrentTime)

import Langchain.Callback.Manager (CallbackEvent (..), CallbackManager, dispatchEvent)
import Langchain.Error (LangchainError, agentError, errorMessage, toolError)
import Langchain.Memory.Core (BaseMemory (..), SomeMemory (..))
import Langchain.Model
import qualified Langchain.Model.Types as M
import Langchain.Stream
  ( CancelToken
  , EventStream
  , StreamEvent
    ( ChainEnd
    , ChainStart
    , LLMChunk
    , LLMEnd
    , LLMStart
    , ToolChunk
    , ToolEnd
    , ToolErrorEvent
    , ToolStart
    )
  , isCancelled
  , liftChatStream
  , liftChatStreamWithCancel
  , tapStreamMessage
  )
import Langchain.Tool
import Langchain.Tool.Binding (ToolBinder (..))

-- | How the agent handles tool execution failures.
data ToolErrorStrategy
  = -- | Feed the error message back to the LLM as a Tool observation so it can retry or recover.
    ContinueOnError
  | -- | Abort the entire agent loop immediately on any tool error.
    FailOnError
  deriving (Eq, Show)

-- | Strategy for detecting runaway loops where the model repeatedly invokes identical tools with identical arguments.
data LoopDetectionStrategy
  = -- | Allow consecutive repeat tool calls without intervention (default for backwards compatibility).
    AllowRepeats
  | -- | After @n@ consecutive identical tool call signatures, skip tool execution and inject an observation warning to the LLM.
    WarnOnRepeat !Int
  | -- | After @n@ consecutive identical tool call signatures, abort execution immediately with an 'AgentError' and notify 'CallbackManager'.
    AbortOnRepeat !Int
  deriving (Eq, Show)

-- | A single step in the ReAct reasoning chain.
data AgentStep
  = -- | The model decided to call one or more tools.
    AgentAction Message [ToolCall]
  | -- | The model produced a final answer (no tool calls).
    AgentFinish Message
  deriving (Eq, Show)

-- | Complete reasoning trace: intermediate steps + final answer.
data AgentTrace = AgentTrace
  { traceSteps :: [(AgentStep, [Message])]
  -- ^ Each pair is (the step, observation messages produced)
  , traceFinalAnswer :: Message
  -- ^ The final answer message
  , traceIterations :: !Int
  -- ^ Total reasoning iterations executed
  }
  deriving (Eq, Show)

{- | ReAct Agent configuration.

Holds the LLM model, the effect-polymorphic tools @[Tool m]@, and optional
configuration for memory, callbacks, iterations, model config, and error strategy.
Use 'defaultReActAgent' to construct, then configure with the @with*@ builder functions.
-}
data ReActAgent model m = ReActAgent
  { agentModel :: model
  -- ^ The LLM model backing this agent
  , agentTools :: [Tool m]
  -- ^ Available tools the agent can invoke in monad @m@
  , agentMaxIterations :: Int
  -- ^ Maximum reasoning iterations before giving up (default: 100)
  , agentSystemPrompt :: Maybe Text
  -- ^ Optional system prompt prepended to every invocation
  , agentModelConfig :: Maybe (ModelConfig model)
  -- ^ Optional provider-specific model configuration (temperature, top_p, etc.)
  , agentMemory :: Maybe SomeMemory
  -- ^ Optional 'BaseMemory' for persistent conversation history
  , agentCallbackManager :: Maybe CallbackManager
  -- ^ Optional callback manager for observability hooks
  , agentStopCondition :: Maybe (Message -> Bool)
  -- ^ Optional early-stop predicate checked after each LLM response
  , agentToolErrorStrategy :: ToolErrorStrategy
  -- ^ How to handle tool execution errors (default: 'ContinueOnError')
  , agentStreamingTools :: [StreamingTool m]
  -- ^ Optional streaming tools capable of emitting incremental 'ToolChunk' events
  , agentLoopDetection :: !LoopDetectionStrategy
  -- ^ Strategy for runaway consecutive duplicate tool-call loop detection (default: 'AllowRepeats')
  , agentMaxDelegationDepth :: !Int
  -- ^ Maximum allowed delegation depth for nested sub-agents (default: 5)
  }

{- | Construct a ReAct agent with sensible defaults.

All optional fields start as 'Nothing' / defaults. Use the @with*@ functions to configure.
-}
defaultReActAgent ::
  -- | The LLM chat model backing this agent
  model ->
  -- | Available effect-polymorphic tools
  [Tool m] ->
  -- | Initialized ReActAgent with default settings
  ReActAgent model m
defaultReActAgent model tools =
  ReActAgent
    { agentModel = model
    , agentTools = tools
    , agentMaxIterations = 100
    , agentSystemPrompt = Nothing
    , agentModelConfig = Nothing
    , agentMemory = Nothing
    , agentCallbackManager = Nothing
    , agentStopCondition = Nothing
    , agentToolErrorStrategy = ContinueOnError
    , agentStreamingTools = []
    , agentLoopDetection = AllowRepeats
    , agentMaxDelegationDepth = 5
    }

{- | Attach a 'BaseMemory' instance for persistent conversation history.

When memory is set, 'runReActAgent' will:

1. Load existing messages from memory before the first LLM call.
2. Save the user query + final answer to memory after completion.
-}
withMemory ::
  (BaseMemory mem) =>
  -- | BaseMemory instance to store conversation messages
  mem ->
  -- | Current agent instance
  ReActAgent model m ->
  -- | Agent configured with persistent memory
  ReActAgent model m
withMemory mem agent = agent {agentMemory = Just (SomeMemory mem)}

-- | Remove memory from the agent.
withoutMemory ::
  -- | Current agent instance
  ReActAgent model m ->
  -- | Agent with memory detached
  ReActAgent model m
withoutMemory agent = agent {agentMemory = Nothing}

-- | Set the system prompt that instructs the agent's persona and behavior.
withSystemPrompt ::
  -- | System instructions / persona text
  Text ->
  -- | Current agent instance
  ReActAgent model m ->
  -- | Agent configured with system prompt
  ReActAgent model m
withSystemPrompt prompt agent = agent {agentSystemPrompt = Just prompt}

-- | Override the maximum number of reasoning iterations (default: 10).
withMaxIterations ::
  -- | Maximum reasoning loop iterations before aborting
  Int ->
  -- | Current agent instance
  ReActAgent model m ->
  -- | Agent configured with iteration limit
  ReActAgent model m
withMaxIterations n agent = agent {agentMaxIterations = n}

{- | Attach a provider-specific model configuration (temperature, top_p, etc.).

This config is merged with the tool-binding config on every 'invoke' call,
giving you full control over LLM parameters without losing tool support.
-}
withModelConfig ::
  -- | Provider-specific model configuration (temperature, top_p, etc.)
  Maybe (ModelConfig model) ->
  -- | Current agent instance
  ReActAgent model m ->
  -- | Agent configured with model options
  ReActAgent model m
withModelConfig cfg agent = agent {agentModelConfig = cfg}

{- | Attach a 'CallbackManager' for observability.

When set, the agent emits 'OnToolStart' / 'OnToolEnd' / 'OnError' events
for each tool invocation in the reasoning loop.
-}
withCallbackManager ::
  -- | Callback manager for observability event hooks
  CallbackManager ->
  -- | Current agent instance
  ReActAgent model m ->
  -- | Agent configured with callback manager
  ReActAgent model m
withCallbackManager mgr agent = agent {agentCallbackManager = Just mgr}

{- | Set a custom stop condition checked after each LLM response.

If the predicate returns 'True' for the LLM's response message, the agent
immediately returns that message as the final answer, even if the LLM
included tool calls.
-}
withStopCondition ::
  -- | Predicate checked after each LLM response for early exit
  (Message -> Bool) ->
  -- | Current agent instance
  ReActAgent model m ->
  -- | Agent configured with custom stopping condition
  ReActAgent model m
withStopCondition cond agent = agent {agentStopCondition = Just cond}

{- | Set the tool error handling strategy.

* 'ContinueOnError' (default): feed error text back to the LLM as a Tool observation.
* 'FailOnError': abort the entire agent loop on the first tool error.
-}
withToolErrorStrategy ::
  -- | Strategy on tool failure ('ContinueOnError' or 'FailOnError')
  ToolErrorStrategy ->
  -- | Current agent instance
  ReActAgent model m ->
  -- | Agent configured with error handling strategy
  ReActAgent model m
withToolErrorStrategy strategy agent = agent {agentToolErrorStrategy = strategy}

-- | Attach 'StreamingTool's that emit incremental 'ToolChunk' events during streaming execution.
withStreamingTools ::
  [StreamingTool m] ->
  ReActAgent model m ->
  ReActAgent model m
withStreamingTools sTools agent = agent {agentStreamingTools = sTools}

{- | Set the runaway loop detection strategy (default: 'AllowRepeats').

* 'AllowRepeats': no intervention on repeated identical tool calls.
* 'WarnOnRepeat': skip execution and inject a warning observation once consecutive duplicates reach @n@.
* 'AbortOnRepeat': immediately terminate the reasoning loop and throw 'AgentError' once consecutive duplicates reach @n@.
-}
withLoopDetection ::
  LoopDetectionStrategy ->
  ReActAgent model m ->
  ReActAgent model m
withLoopDetection strat agent = agent {agentLoopDetection = strat}

-- | Set runaway loop detection to abort after @n@ consecutive duplicate tool calls.
withMaxConsecutiveIdenticalToolCalls ::
  Int ->
  ReActAgent model m ->
  ReActAgent model m
withMaxConsecutiveIdenticalToolCalls n = withLoopDetection (AbortOnRepeat n)

-- | Set the maximum allowed agent delegation depth (default: 5).
withMaxDelegationDepth ::
  Int ->
  ReActAgent model m ->
  ReActAgent model m
withMaxDelegationDepth d agent = agent {agentMaxDelegationDepth = d}

-- | Classify a model response 'Message' as 'AgentAction' or 'AgentFinish'.
classifyStepResponse :: Message -> AgentStep
classifyStepResponse responseMsg =
  case messageToolCalls responseMsg of
    Just tcs@(_ : _) -> AgentAction responseMsg tcs
    _ -> AgentFinish responseMsg

allAgentTools :: ReActAgent model m -> [Tool m]
allAgentTools ReActAgent {..} = agentTools ++ map streamingToolDef agentStreamingTools

{- | Run a single step of ReAct reasoning.

Sends the current message history (with tools bound) to the LLM and
classifies the response as either 'AgentAction' (tool calls requested)
or 'AgentFinish' (final answer).
-}
reactStep ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  -- | The chat model to invoke
  model ->
  -- | Available tools to bind to the model
  [Tool m] ->
  -- | Current conversation message history
  [Message] ->
  -- | Optional provider-specific model configuration
  Maybe (ModelConfig model) ->
  -- | Step classification: 'AgentAction' or 'AgentFinish'
  m AgentStep
reactStep model tools history userCfg = do
  let toolCfg = bindToolsConfig @model tools userCfg
  responseMsg <- invoke model history toolCfg
  pure $ classifyStepResponse responseMsg

reactStepStreamWith ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  Maybe CancelToken ->
  model ->
  [Tool m] ->
  [Message] ->
  Maybe (ModelConfig model) ->
  ConduitT () StreamEvent m AgentStep
reactStepStreamWith mbTok model tools history userCfg = do
  let toolCfg = bindToolsConfig @model tools userCfg
      liftFn = maybe liftChatStream liftChatStreamWithCancel mbTok
  responseMsg <- liftFn (stream model history toolCfg) .| tapStreamMessage
  pure $ classifyStepResponse responseMsg

{- | Run a single step of ReAct reasoning using streaming LLM invocation.

Forwards all 'StreamEvent's from the underlying 'ChatModel' 'stream'
(including 'LLMStart', 'LLMChunk' with text and 'toolCallDelta', and 'LLMEnd')
downstream in real time, and returns the resulting 'AgentStep'.
-}
reactStepStream ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  -- | The chat model to stream
  model ->
  -- | Available tools to bind to the model
  [Tool m] ->
  -- | Current conversation message history
  [Message] ->
  -- | Optional provider-specific model configuration
  Maybe (ModelConfig model) ->
  -- | Stream of LLM events returning the step classification
  ConduitT () StreamEvent m AgentStep
reactStepStream = reactStepStreamWith Nothing

buildInitialHistory ::
  (MonadIO m, MonadError LangchainError m) =>
  ReActAgent model m ->
  [Message] ->
  Text ->
  m [Message]
buildInitialHistory ReActAgent {..} userMsgs caller = do
  memoryMsgs <- case agentMemory of
    Nothing -> pure []
    Just (SomeMemory mem) -> messages mem

  -- When an explicit agent system prompt is provided, avoid duplicate system prompts
  -- by stripping any default system prompt that may already exist in memory.
  let memWithoutSystem = case agentSystemPrompt of
        Just _ -> filter (\m -> messageRole m /= System) memoryMsgs
        Nothing -> memoryMsgs
      sysPrefix = maybe [] (\p -> [systemMessage p]) agentSystemPrompt
      initialHistory = sysPrefix ++ memWithoutSystem ++ userMsgs

  when (null initialHistory) $
    throwError $
      agentError
        "Cannot invoke ReAct agent with empty message history (no user query, memory, or system prompt)"
        (Just "ReActAgent")
        (Just caller)

  pure initialHistory

persistTurnMemory ::
  (MonadIO m, MonadError LangchainError m) =>
  ReActAgent model m ->
  [Message] ->
  Message ->
  m ()
persistTurnMemory ReActAgent {..} userMsgs finalMsg =
  case agentMemory of
    Nothing -> pure ()
    Just (SomeMemory mem) -> do
      mapM_ (addMessage mem) userMsgs
      addMessage mem finalMsg

toolObservationMessage :: Text -> Text -> Text -> Message
toolObservationMessage tName tcId txt =
  (textMessage M.Tool txt)
    { M.messageName = Just tName
    , M.messageToolId = Just tcId
    }

dispatchCb :: MonadIO m => Maybe CallbackManager -> CallbackEvent -> m ()
dispatchCb Nothing _ = pure ()
dispatchCb (Just mgr) ev = dispatchEvent mgr ev

reactRunId :: Text
reactRunId = "react-agent-run"

-- | Execute a ReAct loop step-by-step, parameterized over the LLM step and tool executors.
runLoopWith ::
  forall model m n.
  (MonadIO n, MonadError LangchainError n) =>
  Maybe CancelToken ->
  ReActAgent model m ->
  ([Message] -> n AgentStep) ->
  (ToolCall -> n Message) ->
  [Message] ->
  Int ->
  [(AgentStep, [Message])] ->
  n AgentTrace
runLoopWith mbTok agent@ReActAgent {..} runStep execTool = go Nothing 0
  where
    checkCancelled = maybe (pure False) isCancelled mbTok

    execToolsWithCancel [] acc = pure (reverse acc)
    execToolsWithCancel (tc : rest) acc = do
      cancelled <- checkCancelled
      if cancelled
        then pure (reverse acc)
        else do
          obs <- execTool tc
          execToolsWithCancel rest (obs : acc)

    toolSignature tcs = [(toolCallName tc, toolCallArguments tc) | tc <- tcs]

    go mbLastSig repeatCount history maxIter accSteps
      | maxIter <= 0 = do
          let maxErr =
                "ReAct Agent exceeded maximum iterations ("
                  <> T.pack (show agentMaxIterations)
                  <> ")"
          now <- liftIO getCurrentTime
          dispatchCb agentCallbackManager (OnError "ReActAgent" maxErr now)
          throwError $
            agentError
              maxErr
              (Just "ReActAgent")
              (Just "runReActAgent")
      | otherwise = do
          cancelledBefore <- checkCancelled
          if cancelledBefore
            then pure $ AgentTrace accSteps (assistantMessage "") (length accSteps)
            else do
              llmStartTime <- liftIO getCurrentTime
              dispatchCb
                agentCallbackManager
                (OnLLMStart "ReActAgent" (map extractMessageText history) llmStartTime)

              eStep <- (Right <$> runStep history) `catchError` (pure . Left)
              llmEndTime <- liftIO getCurrentTime
              let llmLatency = round (realToFrac (diffUTCTime llmEndTime llmStartTime) * 1000000 :: Double) :: Int
              case eStep of
                Left err -> do
                  dispatchCb agentCallbackManager (OnError "ReActAgent" (errorMessage err) llmEndTime)
                  throwError err
                Right step -> case step of
                  AgentFinish finalMsg -> do
                    dispatchCb
                      agentCallbackManager
                      (OnLLMEnd "ReActAgent" (extractMessageText finalMsg) llmLatency llmEndTime)
                    let finalSteps = accSteps ++ [(step, [])]
                    pure $ AgentTrace finalSteps finalMsg (length finalSteps)
                  AgentAction respMsg tcs -> do
                    dispatchCb
                      agentCallbackManager
                      (OnLLMEnd "ReActAgent" (extractMessageText respMsg) llmLatency llmEndTime)
                    cancelledAfterStep <- checkCancelled
                    if cancelledAfterStep
                      then do
                        let finalSteps = accSteps ++ [(step, [])]
                        pure $ AgentTrace finalSteps respMsg (length finalSteps)
                      else case agentStopCondition of
                        Just cond
                          | cond respMsg -> do
                              let finalSteps = accSteps ++ [(step, [])]
                              pure $ AgentTrace finalSteps respMsg (length finalSteps)
                        _ -> do
                          let currentSig = toolSignature tcs
                              nextRepeatCount =
                                case mbLastSig of
                                  Just prevSig | prevSig == currentSig -> repeatCount + 1
                                  _ -> 1
                          case agentLoopDetection of
                            AbortOnRepeat maxR
                              | nextRepeatCount >= max 1 maxR -> do
                                  let abortErr =
                                        "Runaway loop detected: consecutive identical tool calls ("
                                          <> T.pack (show nextRepeatCount)
                                          <> " repetitions)"
                                  now <- liftIO getCurrentTime
                                  dispatchCb agentCallbackManager (OnError "ReActAgent" abortErr now)
                                  throwError $
                                    agentError
                                      abortErr
                                      (Just "ReActAgent")
                                      (Just "runLoopWith:loopDetection")
                            WarnOnRepeat maxR
                              | nextRepeatCount >= max 1 maxR -> do
                                  let warnText tc =
                                        "Loop warning: tool '"
                                          <> toolCallName tc
                                          <> "' called with identical arguments "
                                          <> T.pack (show nextRepeatCount)
                                          <> " times consecutively. Please change your approach, adjust arguments, or provide your final answer."
                                      warnMsgs =
                                        [ toolObservationMessage
                                            (toolCallName tc)
                                            (toolCallId tc)
                                            (warnText tc)
                                        | tc <- tcs
                                        ]
                                      newHistory = history ++ [respMsg] ++ warnMsgs
                                      newSteps = accSteps ++ [(step, warnMsgs)]
                                  go (Just currentSig) nextRepeatCount newHistory (maxIter - 1) newSteps
                            _ -> do
                              obsMsgs <- execToolsWithCancel tcs []
                              let newHistory = history ++ [respMsg] ++ obsMsgs
                                  newSteps = accSteps ++ [(step, obsMsgs)]
                              let _ = agent
                              go (Just currentSig) nextRepeatCount newHistory (maxIter - 1) newSteps

handleToolOutcome ::
  (MonadIO n, MonadError LangchainError n) =>
  ReActAgent model m ->
  ToolCall ->
  (StreamEvent -> n ()) ->
  Either LangchainError Text ->
  Int ->
  n Message
handleToolOutcome ReActAgent {..} tc emitStream eRes durationMicros = do
  let tName = toolCallName tc
  endTime <- liftIO getCurrentTime
  case eRes of
    Left err -> do
      let errTxt = "Error executing tool " <> tName <> ": " <> errorMessage err
      dispatchCb agentCallbackManager (OnError tName errTxt endTime)
      emitStream $ ToolErrorEvent reactRunId tName err
      when (agentToolErrorStrategy == FailOnError) $
        throwError $
          agentError errTxt (Just "ReActAgent") (Just $ "executeTool:" <> tName)
      pure $ toolObservationMessage tName (toolCallId tc) errTxt
    Right outTxt -> do
      dispatchCb agentCallbackManager (OnToolEnd tName outTxt durationMicros endTime)
      emitStream $ ToolEnd reactRunId tName (toJSON outTxt)
      pure $ toolObservationMessage tName (toolCallId tc) outTxt

executeToolSync ::
  forall model m.
  (MonadIO m, MonadError LangchainError m) =>
  ReActAgent model m ->
  ToolCall ->
  m Message
executeToolSync agent@ReActAgent {..} tc = do
  let tName = toolCallName tc
      tools = allAgentTools agent
  now <- liftIO getCurrentTime
  dispatchCb agentCallbackManager (OnToolStart tName (toolCallArguments tc) now)
  case find (\t -> toolName t == tName) tools of
    Nothing -> do
      let notFoundMsg = "Error: Tool not found: " <> tName
          err = agentError notFoundMsg (Just "ReActAgent") (Just $ "executeTool:" <> tName)
      handleToolOutcome agent tc (const (pure ())) (Left err) 0
    Just tool -> do
      startTime <- liftIO getCurrentTime
      eRes <- toolExecute tool (toolCallArguments tc)
      endTime <- liftIO getCurrentTime
      let durationMicros = round (realToFrac (diffUTCTime endTime startTime) * 1000000 :: Double) :: Int
      handleToolOutcome agent tc (const (pure ())) eRes durationMicros

executeToolStream ::
  forall model m.
  (MonadIO m, MonadError LangchainError m) =>
  ReActAgent model m ->
  ToolCall ->
  ConduitT () StreamEvent m Message
executeToolStream agent@ReActAgent {..} tc = do
  let tName = toolCallName tc
  now <- liftIO getCurrentTime
  dispatchCb agentCallbackManager (OnToolStart tName (toolCallArguments tc) now)
  yield $ ToolStart reactRunId tName (toolCallArguments tc)
  case find (\st -> toolName (streamingToolDef st) == tName) agentStreamingTools of
    Just sTool -> do
      startTime <- liftIO getCurrentTime
      eRes <- mapOutput (ToolChunk reactRunId tName) (streamingToolExecute sTool (toolCallArguments tc))
      endTime <- liftIO getCurrentTime
      let durationMicros = round (realToFrac (diffUTCTime endTime startTime) * 1000000 :: Double) :: Int
      handleToolOutcome agent tc yield eRes durationMicros
    Nothing ->
      case find (\t -> toolName t == tName) agentTools of
        Nothing -> do
          let notFoundMsg = "Error: Tool not found: " <> tName
              err = agentError notFoundMsg (Just "ReActAgent") (Just $ "executeTool:" <> tName)
          handleToolOutcome agent tc yield (Left err) 0
        Just tool -> do
          startTime <- liftIO getCurrentTime
          eRes <- lift $ toolExecute tool (toolCallArguments tc)
          endTime <- liftIO getCurrentTime
          let durationMicros = round (realToFrac (diffUTCTime endTime startTime) * 1000000 :: Double) :: Int
          handleToolOutcome agent tc yield eRes durationMicros

{- | Execute the full ReAct reasoning loop, returning only the final answer.

For the full reasoning trace, use 'runReActAgentWithTrace'.
For streaming execution, use 'streamReActAgent' or 'runReActAgentStream'.

When the agent has a 'BaseMemory' attached:

1. Existing conversation history is loaded from memory.
2. The user's input messages are appended.
3. After the loop finishes, both the user input and final answer are saved to memory.
-}
runReActAgent ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  -- | Configured ReAct agent instance
  ReActAgent model m ->
  -- | Input user messages for this turn
  [Message] ->
  -- | The final assistant answer message
  m Message
runReActAgent agent userMsgs = traceFinalAnswer <$> runReActAgentWithTrace agent userMsgs

{- | Execute the full ReAct reasoning loop, returning both the final answer
and the complete intermediate reasoning trace.

Tool calls within a single step are executed in sequence within the @m@ monad,
and the observations are fed back to the LLM for the next reasoning step.

The trace includes every 'AgentStep' and the observation messages produced
at each iteration, which is invaluable for debugging and observability.
-}
runReActAgentWithTrace ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  -- | Configured ReAct agent instance
  ReActAgent model m ->
  -- | Input user messages for this turn
  [Message] ->
  -- | Full reasoning trace containing all intermediate steps and final answer
  m AgentTrace
runReActAgentWithTrace agent@ReActAgent {..} userMsgs = do
  initialHistory <- buildInitialHistory agent userMsgs "runReActAgentWithTrace"
  let tools = allAgentTools agent
  trace <-
    runLoopWith
      Nothing
      agent
      (\hist -> reactStep agentModel tools hist agentModelConfig)
      (executeToolSync agent)
      initialHistory
      agentMaxIterations
      []
  persistTurnMemory agent userMsgs (traceFinalAnswer trace)
  pure trace

streamReActAgentTraceWith ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  Maybe CancelToken ->
  ReActAgent model m ->
  [Message] ->
  ConduitT () StreamEvent m AgentTrace
streamReActAgentTraceWith mbTok agent@ReActAgent {..} userMsgs = do
  initialHistory <- lift $ buildInitialHistory agent userMsgs "streamReActAgentWithTrace"
  let tools = allAgentTools agent
  yield $ ChainStart reactRunId "ReActAgent" (toJSON initialHistory)
  trace <-
    runLoopWith
      mbTok
      agent
      (\hist -> reactStepStreamWith mbTok agentModel tools hist agentModelConfig)
      (executeToolStream agent)
      initialHistory
      agentMaxIterations
      []
  cancelled <- maybe (pure False) isCancelled mbTok
  unless cancelled $ do
    lift $ persistTurnMemory agent userMsgs (traceFinalAnswer trace)
    yield $ ChainEnd reactRunId "ReActAgent" (toJSON (traceFinalAnswer trace))
  pure trace

{- | Execute the full ReAct reasoning loop as a Conduit stream of 'StreamEvent's,
returning the complete 'AgentTrace'.

Emits 'ChainStart', followed by 'LLMStart' / 'LLMChunk' / 'LLMEnd' for each
reasoning turn and 'ToolStart' / 'ToolChunk' / 'ToolEnd' / 'ToolErrorEvent' for
each tool invocation, concluding with 'ChainEnd'.
-}
streamReActAgentWithTrace ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  -- | Configured ReAct agent instance
  ReActAgent model m ->
  -- | Input user messages for this turn
  [Message] ->
  -- | Event stream returning the full reasoning trace
  ConduitT () StreamEvent m AgentTrace
streamReActAgentWithTrace = streamReActAgentTraceWith Nothing

{- | Execute the full ReAct reasoning loop as an 'EventStream'.

Suitable for composition with 'collectEvents', 'runStreamWith', or any Conduit sink.
-}
streamReActAgent ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  -- | Configured ReAct agent instance
  ReActAgent model m ->
  -- | Input user messages for this turn
  [Message] ->
  -- | Stream of agent lifecycle, LLM, and tool events
  EventStream m
streamReActAgent agent userMsgs = do
  _ <- streamReActAgentWithTrace agent userMsgs
  pure ()

-- | Outcome of an interruptible streaming ReAct turn.
data InterruptibleResult
  = -- | Turn completed normally with the final assistant message.
    Completed !Message
  | -- | Turn was cancelled via 'CancelToken', returning any partial assistant text emitted.
    Cancelled !Message
  deriving (Eq, Show)

{- | Execute an interruptible streaming ReAct turn using a cooperative 'CancelToken'.

Safe for @LangchainT () IO@ callers without @ResourceT@. When cancelled mid-turn,
upstream streaming stops promptly, conversation memory is left unmodified, and
'Cancelled' returns the partial assistant message streamed so far.
-}
runReActAgentStreamInterruptible ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  CancelToken ->
  ReActAgent model m ->
  [Message] ->
  (StreamEvent -> m ()) ->
  m InterruptibleResult
runReActAgentStreamInterruptible tok agent userMsgs onEvent = do
  cancelledStart <- isCancelled tok
  if cancelledStart
    then pure $ Cancelled (assistantMessage "")
    else do
      chunksRef <- liftIO $ newIORef ([] :: [Text])
      let trackAndForward ev = do
            liftIO $ case ev of
              LLMStart {} -> writeIORef chunksRef []
              LLMChunk _ txt _ | not (T.null txt) -> modifyIORef' chunksRef (txt :)
              LLMEnd _ msg _ -> writeIORef chunksRef [extractMessageText msg]
              _ -> pure ()
            onEvent ev
      trace <-
        runConduit
          ( streamReActAgentTraceWith (Just tok) agent userMsgs
              `fuseUpstream` CL.mapM_ trackAndForward
          )
      cancelledEnd <- isCancelled tok
      if cancelledEnd
        then do
          partialTxt <- liftIO (T.concat . reverse <$> readIORef chunksRef)
          pure $ Cancelled (assistantMessage partialTxt)
        else pure $ Completed (traceFinalAnswer trace)

{- | Execute the ReAct agent in streaming mode, invoking a callback for every
'StreamEvent' as it arrives and returning the final assistant 'Message'.
-}
runReActAgentStream ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  -- | Configured ReAct agent instance
  ReActAgent model m ->
  -- | Input user messages for this turn
  [Message] ->
  -- | Callback invoked on each 'StreamEvent' in real time
  (StreamEvent -> m ()) ->
  -- | The final assistant answer message
  m Message
runReActAgentStream agent userMsgs onEvent =
  traceFinalAnswer
    <$> runConduit (streamReActAgentWithTrace agent userMsgs `fuseUpstream` CL.mapM_ onEvent)

-- | JSON Schema describing the input expected by agent handoff tools.
handoffSchema :: Value
handoffSchema =
  object
    [ "type" .= ("object" :: Text)
    , "properties"
        .= object
          [ "task"
              .= object
                [ "type" .= ("string" :: Text)
                , "description" .= ("The task or query for the specialist agent" :: Text)
                ]
          ]
    , "required" .= (["task"] :: [Text])
    ]

-- | Extract the task query string from tool arguments.
extractAgentTask :: Text -> Value -> Either LangchainError Text
extractAgentTask toolNm = \case
  String s | not (T.null (T.strip s)) -> Right s
  Object o -> case parseEither (.:? "task") o of
    Right (Just task) | not (T.null (T.strip task)) -> Right task
    _ -> case parseEither (.:? "query") o of
      Right (Just q) | not (T.null (T.strip q)) -> Right q
      _ -> case parseEither (.:? "prompt") o of
        Right (Just p) | not (T.null (T.strip p)) -> Right p
        _ ->
          Left $
            toolError
              ("Missing or invalid 'task' parameter for agent tool: " <> toolNm)
              (Just toolNm)
              Nothing
  _ ->
    Left $
      toolError
        ("Invalid arguments object for agent tool: " <> toolNm)
        (Just toolNm)
        Nothing

{- | Wrap a 'ReActAgent' as a callable 'Tool' that delegates tasks to the sub-agent.

Accepts @{ "task": "<text>" }@ (or a raw JSON string) and returns the sub-agent's
final answer text. Bounded by 'agentMaxDelegationDepth' to prevent unbounded recursion.
-}
asAgentTool ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  -- | Tool name (e.g., "researcher")
  Text ->
  -- | Tool description explaining the specialist agent's role
  Text ->
  -- | Specialist sub-agent to delegate tasks to
  ReActAgent model m ->
  -- | Wrapped tool ready to be attached to a supervisor agent
  Tool m
asAgentTool name desc subAgent =
  createTool name desc handoffSchema $ \args -> do
    case extractAgentTask name args of
      Left err -> pure (Left err)
      Right task
        | agentMaxDelegationDepth subAgent <= 0 ->
            pure $
              Left $
                agentError
                  ("Maximum agent delegation depth exceeded for sub-agent '" <> name <> "'")
                  (Just "ReActAgent")
                  (Just "asAgentTool")
        | otherwise -> do
            let delegatedAgent =
                  subAgent {agentMaxDelegationDepth = agentMaxDelegationDepth subAgent - 1}
            eRes <- (Right <$> runReActAgent delegatedAgent [userMessage task]) `catchError` (pure . Left)
            pure $ fmap extractMessageText eRes

{- | Wrap a 'ReActAgent' as a 'StreamingTool' forwarding intermediate progress chunks to the caller.

Emits incremental sub-agent text and tool chunks downstream wrapped as 'ToolChunk' events.
-}
asStreamingAgentTool ::
  forall model m.
  (ToolBinder model m, MonadIO m, MonadError LangchainError m) =>
  -- | Tool name (e.g., "researcher")
  Text ->
  -- | Tool description explaining the specialist agent's role
  Text ->
  -- | Specialist sub-agent to delegate tasks to
  ReActAgent model m ->
  -- | Wrapped streaming tool emitting incremental sub-agent chunks
  StreamingTool m
asStreamingAgentTool name desc subAgent =
  createStreamingTool name desc handoffSchema $ \args -> do
    case extractAgentTask name args of
      Left err -> pure (Left err)
      Right task
        | agentMaxDelegationDepth subAgent <= 0 ->
            pure $
              Left $
                agentError
                  ("Maximum agent delegation depth exceeded for sub-agent '" <> name <> "'")
                  (Just "ReActAgent")
                  (Just "asStreamingAgentTool")
        | otherwise -> do
            let delegatedAgent =
                  subAgent {agentMaxDelegationDepth = agentMaxDelegationDepth subAgent - 1}
            ansRef <- liftIO $ newIORef ""
            let captureSub = do
                  trace <- streamReActAgentWithTrace delegatedAgent [userMessage task]
                  liftIO $ writeIORef ansRef (extractMessageText (traceFinalAnswer trace))
                forwardEvents = do
                  mEv <- await
                  case mEv of
                    Nothing -> pure ()
                    Just ev -> do
                      case ev of
                        LLMChunk _ txt Nothing | not (T.null txt) -> yield txt
                        ToolChunk _ _ chunk | not (T.null chunk) -> yield chunk
                        _ -> pure ()
                      forwardEvents
            catchError
              ( do
                  captureSub .| forwardEvents
                  ans <- liftIO $ readIORef ansRef
                  pure $ Right ans
              )
              (pure . Left)