llm-simple-0.4.2.0: src/LLM/Agent/Generate.hs
{- HLINT ignore "Eta reduce" -}
module LLM.Agent.Generate
( streamText,
generateText,
toolBudgetUsedUpPrompt,
)
where
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import LLM.Agent.Events (emitEvent)
import LLM.Agent.ToolUtils
( createGenRequest,
createToolContext,
executeToolsWithAbort,
ToolRoundOutcome (..),
getResolvedTools,
agentRuntimeArgs,
validateAgent,
)
import LLM.Agent.Types
( Agent (agMaxToolRounds, agSystemPrompt),
GenerateEventDetail (..),
RuntimeArgs (..),
ToolMap,
)
import LLM.Core.Abort (isAbortedMaybe)
import LLM.Core.Types
( ChatResponse (..),
Turn (..),
)
import LLM.Core.Usage (Usage (..), emptyUsage)
import LLM.Core.Utils (getToolCalls, toolResult)
import LLM.Generate.Generate
( generateTextWithFallbacksRotated,
streamTextWithFallbacksRotated,
)
import LLM.Generate.Logger (Hooks (..), LogLevel (Warn))
import LLM.Generate.ModelConfig
( ModelWithFallbacks (..),
)
import LLM.Generate.Types
( GenerateError (..),
GenerateErrorResult (..),
GenerateResult,
GenerateTextResult (..),
StreamChunk (..),
)
-- | Run the agent loop until the model returns final text or the run fails.
--
-- On success, 'gtrNewMessages' contains only the turns produced during this
-- call (assistant and tool turns), not the input conversation.
-- Tool rounds continue until the model responds without tool calls or
-- 'Agent.agMaxToolRounds' rounds have run. Then one final model call asks
-- for a text answer ('toolBudgetUsedUpPrompt'); tool calls in that reply
-- are not run, and the run ends with 'GErrToolExceeded'.
--
-- The warnings of 'LLM.Agent.ToolUtils.validateAgent' go to 'onLog' at
-- the start of the run.
generateText ::
Agent ->
ModelWithFallbacks ->
ToolMap Text ->
RuntimeArgs ->
[Turn] ->
IO (Either GenerateErrorResult GenerateTextResult)
generateText agent models toolMap rt initialTurns =
agentLoop
(\a m r t -> generateTextWithFallbacksRotated (createGenRequest id a toolMap r t) m)
agent
models
toolMap
rt
initialTurns
-- | Like 'generateText', but streams token deltas via the callback.
--
-- The callback receives 'StreamChunk' values as the provider responds.
-- Tool rounds still run to completion; streaming applies to each model call.
-- Returns the same 'GenerateTextResult' / 'GenerateErrorResult' as 'generateText'.
streamText ::
(StreamChunk -> IO ()) ->
Agent ->
ModelWithFallbacks ->
ToolMap Text ->
RuntimeArgs ->
[Turn] ->
IO (Either GenerateErrorResult GenerateTextResult)
streamText onChunk agent models toolMap rt initialTurns =
agentLoop
(\a m r t -> streamTextWithFallbacksRotated onChunk (createGenRequest id a toolMap r t) m)
agent
models
toolMap
rt
initialTurns
-- | The line appended to the system prompt for the final model call, made
-- once 'Agent.agMaxToolRounds' tool rounds have run.
toolBudgetUsedUpPrompt :: Text
toolBudgetUsedUpPrompt =
"The tool budget for this run is used up. Answer in text; do not call tools."
-- | Result text for a tool call in the final reply, which is not run.
toolNotRunText :: Text
toolNotRunText = "Not run: tool round limit reached"
-- | The tool loop. @call@ returns the model chain rotated so the model that
-- answered comes first; the next round starts with that chain, so a run that
-- fell back stays on the fallback instead of retrying a failing primary every
-- round. The next 'generateText' / 'streamText' call starts from the caller's
-- chain again.
agentLoop ::
(Agent -> ModelWithFallbacks -> RuntimeArgs -> [Turn] -> IO (GenerateResult (ModelWithFallbacks, ChatResponse))) ->
Agent ->
ModelWithFallbacks ->
ToolMap Text ->
RuntimeArgs ->
[Turn] ->
IO (Either GenerateErrorResult GenerateTextResult)
agentLoop call agent models toolMap rt0 initialTurns = do
mapM_ (rt.rtHooks.onLog Warn) (validateAgent agent toolMap)
emitEvent rt GenerationStarted
go models initialTurns [] emptyUsage 0
where
rt = agentRuntimeArgs agent rt0
finalAgent =
agent
{ agSystemPrompt =
Just (maybe toolBudgetUsedUpPrompt (<> "\n\n" <> toolBudgetUsedUpPrompt) agent.agSystemPrompt)
}
-- New turns are collected newest first and reversed once at the end.
failWith err newRev usage = do
let errResult = GenerateErrorResult err (reverse newRev) usage
emitEvent rt (GenerationFailed err errResult)
pure $ Left errResult
go :: ModelWithFallbacks -> [Turn] -> [Turn] -> Usage -> Int -> IO (Either GenerateErrorResult GenerateTextResult)
go chain currentTurns newRev currentUsage loopCount = do
aborted <- isAbortedMaybe rt.rtAbortSignal
let finalCall = loopCount >= agent.agMaxToolRounds
if aborted
then failWith GErrAborted newRev currentUsage
else do
result <- call (if finalCall then finalAgent else agent) chain rt currentTurns
case result of
Left err -> failWith err newRev currentUsage
Right (nextChain, resp) -> do
let txt = resp.respText
toolCalls = getToolCalls resp
roundUsage = fromMaybe emptyUsage resp.respUsage
newUsage = currentUsage <> roundUsage
-- Authoritative ordered parts from the provider response.
assistantMsg = AssistantMessage resp.respContent
case toolCalls of
[] -> do
let finalTurnsAcc = reverse (assistantMsg : newRev)
successResult = GenerateTextResult rt.rtGenerationId finalTurnsAcc txt newUsage
emitEvent rt (MessageFinalized assistantMsg)
emitEvent rt (GenerationFinished successResult)
pure $ Right successResult
_
| finalCall -> do
-- The tool budget is used up: answer every call with a
-- placeholder so the history stays valid, run none.
let toolTurn = ToolTurn (map (`toolResult` toolNotRunText) toolCalls)
emitEvent rt (MessageCreated assistantMsg)
emitEvent rt (MessageCreated toolTurn)
failWith GErrToolExceeded (toolTurn : assistantMsg : newRev) newUsage
| otherwise -> do
let toolContext = createToolContext agent currentTurns newUsage rt
tools = getResolvedTools id agent toolMap rt
emitEvent rt (MessageCreated assistantMsg)
emitEvent rt (ToolRoundStarted loopCount)
toolResultsE <- executeToolsWithAbort rt.rtAbortSignal rt.rtHooks toolContext tools toolCalls
case toolResultsE of
ToolRoundAborted done notRun -> do
-- Answer every tool call so the history stays valid
-- for the next request: real results for the calls
-- that ran (they may have had side effects), a
-- placeholder for the rest.
let toolTurn = ToolTurn (done ++ map (`toolResult` "Aborted before execution") notRun)
emitEvent rt (MessageCreated toolTurn)
failWith GErrAborted (toolTurn : assistantMsg : newRev) newUsage
ToolRoundCompleted toolResults -> do
let toolTurn = ToolTurn toolResults
emitEvent rt (MessageCreated toolTurn)
emitEvent rt (ToolRoundFinished loopCount)
let turnsToAdd = [assistantMsg, toolTurn]
go nextChain (currentTurns ++ turnsToAdd) (toolTurn : assistantMsg : newRev) newUsage (loopCount + 1)