never executed always true always false
1 module PureClaw.Agent.Loop
2 ( -- * Agent loop
3 runAgentLoop
4 ) where
5
6 import Control.Exception
7 import Control.Monad
8 import Data.IORef
9 import Data.Text (Text)
10 import Data.Text qualified as T
11
12 import PureClaw.Agent.Context
13 import PureClaw.Agent.Env
14 import PureClaw.Agent.SlashCommands
15 import PureClaw.Core.Errors
16 import PureClaw.Handles.Channel
17 import PureClaw.Handles.Log
18 import PureClaw.Providers.Class
19 import PureClaw.Tools.Registry
20
21 -- | Run the main agent loop. Reads messages from the channel, sends
22 -- them to the provider (with tool definitions), handles tool call/result
23 -- cycles, and writes responses back.
24 --
25 -- Slash commands (messages starting with '/') are intercepted and
26 -- handled before being sent to the provider.
27 --
28 -- If no provider is configured ('Nothing' in the IORef), chat messages
29 -- produce a helpful error directing the user to configure credentials.
30 -- Slash commands always work regardless of provider state.
31 --
32 -- Exits cleanly on 'IOException' from the channel (e.g. EOF / Ctrl-D).
33 -- Provider errors are logged and a 'PublicError' is sent to the channel.
34 runAgentLoop :: AgentEnv -> IO ()
35 runAgentLoop env = do
36 _lh_logInfo logger "Agent loop started"
37 go (emptyContext (_env_systemPrompt env))
38 where
39 model = _env_model env
40 channel = _env_channel env
41 logger = _env_logger env
42 registry = _env_registry env
43 tools = registryDefinitions registry
44
45 go ctx = do
46 receiveResult <- try @IOException (_ch_receive channel)
47 case receiveResult of
48 Left _ -> _lh_logInfo logger "Session ended"
49 Right msg
50 | T.null stripped -> go ctx
51 -- INVARIANT: any message beginning with '/' is handled locally and
52 -- NEVER forwarded to the provider. Unknown slash commands get an
53 -- error response rather than silently routing to the LLM.
54 | "/" `T.isPrefixOf` stripped ->
55 case parseSlashCommand stripped of
56 Just cmd -> do
57 _lh_logInfo logger $ "Slash command: " <> stripped
58 ctx' <- executeSlashCommand env cmd ctx
59 go ctx'
60 Nothing -> do
61 _lh_logWarn logger $ "Unrecognized slash command: " <> stripped
62 _ch_send channel
63 (OutgoingMessage ("Unknown command: " <> stripped
64 <> "\nType /status for session info, /help for available commands."))
65 go ctx
66 | otherwise -> do
67 mProvider <- readIORef (_env_provider env)
68 case mProvider of
69 Nothing -> do
70 _ch_send channel (OutgoingMessage noProviderMessage)
71 go ctx
72 Just provider -> do
73 let userMsg = textMessage User stripped
74 ctx' = addMessage userMsg ctx
75 _lh_logDebug logger $
76 "Sending " <> T.pack (show (length (contextMessages ctx'))) <> " messages"
77 handleCompletion provider ctx'
78 where stripped = T.strip (_im_content msg)
79
80 handleCompletion provider ctx = do
81 let req = CompletionRequest
82 { _cr_model = model
83 , _cr_messages = contextMessages ctx
84 , _cr_systemPrompt = contextSystemPrompt ctx
85 , _cr_maxTokens = Just 4096
86 , _cr_tools = tools
87 , _cr_toolChoice = Nothing
88 }
89 responseRef <- newIORef (Nothing :: Maybe CompletionResponse)
90 streamedRef <- newIORef False
91 providerResult <- try @SomeException $
92 completeStream provider req $ \case
93 StreamText t -> do
94 _ch_sendChunk channel (ChunkText t)
95 writeIORef streamedRef True
96 StreamDone resp ->
97 writeIORef responseRef (Just resp)
98 _ -> pure ()
99 case providerResult of
100 Left e -> do
101 _lh_logError logger $ "Provider error: " <> T.pack (show e)
102 _ch_sendError channel (TemporaryError "Something went wrong. Please try again.")
103 go ctx
104 Right () -> do
105 wasStreaming <- readIORef streamedRef
106 when wasStreaming $ _ch_sendChunk channel ChunkDone
107 mResp <- readIORef responseRef
108 case mResp of
109 Nothing -> go ctx -- shouldn't happen
110 Just response -> do
111 let calls = toolUseCalls response
112 text = responseText response
113 ctx' = recordUsage (_crsp_usage response)
114 $ addMessage (Message Assistant (_crsp_content response)) ctx
115 -- If not streamed, send the full text
116 unless (wasStreaming || T.null (T.strip text)) $
117 _ch_send channel (OutgoingMessage text)
118 -- If there are tool calls, execute them and continue
119 if null calls
120 then go ctx'
121 else do
122 results <- mapM executeCall calls
123 let resultMsg = toolResultMessage results
124 ctx'' = addMessage resultMsg ctx'
125 _lh_logDebug logger $
126 "Executed " <> T.pack (show (length results)) <> " tool calls, continuing"
127 handleCompletion provider ctx''
128
129 executeCall (callId, name, input) = do
130 _lh_logInfo logger $ "Tool call: " <> name
131 result <- executeTool registry name input
132 case result of
133 Nothing -> do
134 _lh_logWarn logger $ "Unknown tool: " <> name
135 pure (callId, [TRPText ("Unknown tool: " <> name)], True)
136 Just (parts, isErr) -> do
137 when isErr $ _lh_logWarn logger $ "Tool error in " <> name <> ": " <> partsToText parts
138 pure (callId, parts, isErr)
139
140 partsToText :: [ToolResultPart] -> Text
141 partsToText parts = T.intercalate "\n" [t | TRPText t <- parts]
142
143 -- | Message shown when user sends a chat message but no provider is configured.
144 noProviderMessage :: Text
145 noProviderMessage = T.intercalate "\n"
146 [ "No provider configured. To start chatting, configure your provider with:"
147 , ""
148 , " /provider <PROVIDER>"
149 , ""
150 ]