Skip to main content

llimorse_chat/
agent.rs

1//! Handle the agent-running part.
2
3use super::history::{ChatHistory, HistoryEntryType};
4use super::ui::{self, AgentId};
5use anyhow::Result;
6use futures::{FutureExt, StreamExt};
7use llimorse::line_format::{ChatMessage, ToolCall};
8use llimorse::{Agent, StreamingChunk};
9use std::collections::VecDeque;
10use std::mem;
11use std::sync::{Arc, Mutex};
12use tokio::sync::mpsc;
13
14/// State of the agent-running part of the llimorse-based chat application
15pub(super) struct ChatAgent {
16    /// The chat history as shared with the agent
17    chat_history: Arc<Mutex<ChatHistory>>,
18
19    /// Notify the UI to redraw
20    ui_notifications: Arc<mpsc::UnboundedSender<ui::Notification>>,
21
22    /// Notifications from the application
23    ///
24    /// Generally `Some(_)`, but we need to `.take()` it during tool call execution so we can use
25    /// this mutably while the tool call executor has `&self` borrowed.
26    incoming: Option<Incoming>,
27}
28
29/// Incoming notification processing
30struct Incoming {
31    /// Notifications from the application
32    notifications: mpsc::UnboundedReceiver<Notification>,
33
34    /// User messages queued
35    queued_messages: VecDeque<String>,
36}
37
38/// Notifications to be sent to the agent
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub(super) enum Notification {
41    /// Notify of an exit event
42    Exit,
43
44    /// Queue the given prompt until the next cycle where we can naturally submit it
45    QueuePrompt(String),
46
47    /// Force submitting all queued prompts *right now*
48    ForceSubmitQueued,
49
50    /// Submit the current state without a new user message (e.g. continue a resumed
51    /// session)
52    Continue,
53}
54
55impl ChatAgent {
56    /// Create a new instance.
57    pub fn new(
58        chat_history: Arc<Mutex<ChatHistory>>,
59        notifications: mpsc::UnboundedReceiver<Notification>,
60        ui_notifications: Arc<mpsc::UnboundedSender<ui::Notification>>,
61    ) -> Self {
62        ChatAgent {
63            chat_history,
64            incoming: Some(Incoming {
65                notifications,
66                queued_messages: VecDeque::new(),
67            }),
68            ui_notifications,
69        }
70    }
71
72    /// Run agent requests in a loop until the exit flag is set (or an error occurs).
73    ///
74    /// Will itself set the exit flag before returning.
75    pub async fn run(&mut self, agent: llimorse::Agent) -> Result<()> {
76        let result = self.do_run(agent).await;
77        let _ = self.ui_notifications.send(ui::Notification::Exit);
78        result
79    }
80
81    /// Process incoming notifications until finding a prompt, a continue, or an exit.
82    ///
83    /// Return `false` if the channel is closed (or an exit event is received).
84    ///
85    /// The caller **must** submit `self.queued_messages` immediately (because this function
86    /// ignores `ForceSubmitQueued`, assuming the caller will do so).
87    async fn process_notifications(&mut self) -> bool {
88        let incoming = self.incoming.as_mut().expect("Notifications object taken");
89
90        while let Some(notification) = incoming.notifications.recv().await {
91            match notification {
92                Notification::Exit => return false,
93
94                Notification::QueuePrompt(p) => {
95                    incoming.queued_messages.push_back(p);
96                    return true;
97                }
98
99                Notification::Continue => return true,
100
101                // Ignore this, the caller will submit them all right now anyway.
102                Notification::ForceSubmitQueued => (),
103            }
104        }
105
106        false
107    }
108
109    /// Process all available incoming notifications (non-blocking)
110    ///
111    /// Return `false` if an exit event is received.
112    ///
113    /// The caller **must** submit `self.queued_messages` immediately (because this function
114    /// ignores `ForceSubmitQueued`, assuming the caller will do so).
115    fn process_available_notifications(&mut self) -> bool {
116        let incoming = self.incoming.as_mut().expect("Notifications object taken");
117
118        while let Ok(notification) = incoming.notifications.try_recv() {
119            match notification {
120                Notification::Exit => return false,
121
122                Notification::QueuePrompt(p) => incoming.queued_messages.push_back(p),
123
124                // Ignore this, the caller will submit them all right now anyway.
125                Notification::ForceSubmitQueued => (),
126
127                Notification::Continue => (),
128            }
129        }
130
131        true
132    }
133
134    /// Push all messages currently in `self.queued_messages` onto the agent/chat history.
135    ///
136    /// Return if there were any messages.
137    ///
138    /// This does not yet submit a request to the agent.
139    fn submit_queued_user_messages(&mut self, agent: &mut llimorse::Agent) -> bool {
140        let incoming = self.incoming.as_mut().expect("Notifications object taken");
141
142        let messages = mem::take(&mut incoming.queued_messages);
143        let any_messages = !messages.is_empty();
144        for message in messages {
145            let _ = self
146                .ui_notifications
147                .send(ui::Notification::PromptSubmitted);
148            self.push_history(&message, HistoryEntryType::User);
149            agent.push_user(message);
150        }
151
152        any_messages
153    }
154
155    /// Whether a history can be submitted without a new user message: it must top out on a tool
156    /// result (e.g. an interrupted session) or a user message the model has not answered yet.
157    fn can_continue(history: &[ChatMessage]) -> bool {
158        matches!(
159            history.last(),
160            Some(ChatMessage::Tool(_)) | Some(ChatMessage::User(_))
161        )
162    }
163
164    /// Run agent requests in a loop until the exit flag is set (or an error occurs).
165    async fn do_run(&mut self, mut agent: llimorse::Agent) -> Result<()> {
166        while self.process_notifications().await && self.process_available_notifications() {
167            // If there were no queued user messages, we received `Continue` notification; honor
168            // that only if we can actually continue, i.e. if there is anything to submit.
169            if !self.submit_queued_user_messages(&mut agent) && !Self::can_continue(agent.history())
170            {
171                continue;
172            }
173
174            let response = loop {
175                let mut streaming = agent.submit().await?;
176
177                loop {
178                    let incoming = self.incoming.as_mut().expect("Notifications object taken");
179
180                    futures::select! {
181                        chunk = streaming.next() => {
182                            if let Some(chunk) = chunk {
183                                self.process_chunk(chunk?);
184                            } else {
185                                break;
186                            }
187                        }
188
189                        exit = incoming.wait_for_abort().fuse() => if exit {
190                            return Ok(())
191                        } else {
192                            streaming.force_finalize();
193                            break;
194                        }
195                    }
196                }
197
198                drop(streaming);
199
200                let mut incoming = self.incoming.take().expect("Notifications object taken");
201
202                let check_calls = |agent: &Agent, call: &ToolCall| {
203                    self.push_history(
204                        &format!("[{}] {}\n", call.id, agent.display_call(&call.call)),
205                        HistoryEntryType::ToolCall,
206                    );
207                    Ok(())
208                };
209
210                let check_results = |agent: &Agent, call: &ToolCall, result: &Result<String>| {
211                    let name = call.call.name();
212                    let id = &call.id;
213                    match result {
214                        Ok(result) => self.push_history(
215                            &format!(
216                                "=[{name}/{id}]=> {}\n",
217                                agent.display_call_result(&call.call, result)
218                            ),
219                            HistoryEntryType::ToolResultOk,
220                        ),
221                        Err(err) => self.push_history(
222                            &format!("=[{name}/{id}]=> {err}\n"),
223                            HistoryEntryType::ToolResultErr,
224                        ),
225                    }
226                    Ok(())
227                };
228
229                let mut pending = futures::select! {
230                    pending = agent.execute_pending_calls(check_calls, check_results).fuse() => pending,
231                    exit = incoming.wait_for_abort().fuse() => if exit {
232                        return Ok(());
233                    } else {
234                        false // not pending by itself, unless there are actually user messages
235                    }
236                };
237                self.incoming = Some(incoming);
238
239                if !self.process_available_notifications() {
240                    return Ok(());
241                }
242                if self.submit_queued_user_messages(&mut agent) {
243                    pending = true;
244                }
245
246                if !pending {
247                    break agent.history().last().and_then(|cm| {
248                        if let ChatMessage::Assistant(msg) = cm
249                            && let Some(response) = &msg.content
250                            && !response.is_empty()
251                        {
252                            Some(response.clone())
253                        } else {
254                            None
255                        }
256                    });
257                }
258            };
259
260            // The loop can only exit when there are no pending tool calls and no queued
261            // messages: the prompt iteration is complete, and the agent will wait for the
262            // user’s next prompt.
263            let _ = self
264                .ui_notifications
265                .send(ui::Notification::AwaitingPrompt { response });
266        }
267
268        Ok(())
269    }
270
271    /// Process the incoming `chunk` from the LLM (i.e. append it to the history).
272    fn process_chunk(&self, chunk: StreamingChunk) {
273        let (string, kind) = match chunk {
274            StreamingChunk::Content(content) => (content, HistoryEntryType::Content),
275            StreamingChunk::Reasoning(content) => (content, HistoryEntryType::Reasoning),
276        };
277
278        self.push_history(&string, kind);
279    }
280
281    /// Push the given string into the chat history, processing newlines
282    fn push_history(&self, string: &str, kind: HistoryEntryType) {
283        let mut history = self.chat_history.lock().unwrap();
284        // Put user messages on a new line, always, as they can never be streamed content
285        history.push_lines(string, kind, kind == HistoryEntryType::User);
286        drop(history);
287
288        let _ = self.ui_notifications.send(ui::Notification::UpdateAgent {
289            agent_id: AgentId::Main,
290        });
291    }
292}
293
294impl Incoming {
295    /// Process all available incoming notifications, until any abort notification is received
296    ///
297    /// Abort notifications are `Exit` and `ForceSubmitQueued`, or the channel being closed.
298    ///
299    /// Return `true` if we need to exit, `false` if we just need to abort and submit everything
300    /// that’s queued.
301    async fn wait_for_abort(&mut self) -> bool {
302        while let Some(notification) = self.notifications.recv().await {
303            match notification {
304                Notification::Exit => return true,
305                Notification::ForceSubmitQueued => return false,
306
307                Notification::Continue => (),
308
309                Notification::QueuePrompt(p) => self.queued_messages.push_back(p),
310            }
311        }
312
313        true
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::ChatAgent;
320    use llimorse::line_format::{AssistantMessage, ChatMessage, ToolResult, UserMessage};
321
322    #[test]
323    fn can_continue_on_tool_result() {
324        let result = ToolResult::new("call-1".to_string(), Ok("done".to_string()));
325        let history = vec![ChatMessage::Tool(result)];
326        assert!(ChatAgent::can_continue(&history));
327    }
328
329    #[test]
330    fn can_continue_on_unanswered_user_message() {
331        let history = vec![ChatMessage::User(UserMessage {
332            content: "hello".to_string(),
333        })];
334        assert!(ChatAgent::can_continue(&history));
335    }
336
337    #[test]
338    fn cannot_continue_after_assistant_message() {
339        let history = vec![ChatMessage::Assistant(AssistantMessage {
340            reasoning_content: None,
341            content: Some("hi there".to_string()),
342            tool_calls: None,
343        })];
344        assert!(!ChatAgent::can_continue(&history));
345    }
346
347    #[test]
348    fn cannot_continue_on_empty_history() {
349        assert!(!ChatAgent::can_continue(&[]));
350    }
351}