Skip to main content

llimorse_chat/
history.rs

1//! Chat log implementation.
2
3use anyhow::anyhow;
4use llimorse::Agent;
5use llimorse::line_format::{
6    AssistantMessage, ChatMessage, ToolCallParams, ToolResult, UserMessage,
7};
8use std::collections::HashMap;
9
10/// Type of a chat history entry (for formatting)
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum HistoryEntryType {
13    /// Nothing at all (separator)
14    Empty,
15
16    /// User message
17    User,
18
19    /// Assistant reply (actual content)
20    Content,
21
22    /// Assistant reasoning
23    Reasoning,
24
25    /// Tool call (with parameters)
26    ToolCall,
27
28    /// Tool call results (on success)
29    ToolResultOk,
30
31    /// Tool call error
32    ToolResultErr,
33}
34
35/// Chat history data
36#[derive(Debug, Default)]
37pub struct ChatHistory {
38    /// Full chat history (split into lines, but not broken by terminal width)
39    lines: Vec<(String, HistoryEntryType)>,
40
41    /// Not-yet-resolved tool calls (for [`Self::push_raw()`])
42    open_tool_calls: HashMap<String, ToolCallParams>,
43}
44
45impl ChatHistory {
46    /// Push and format a raw [`ChatMessage`] into the history.
47    pub fn push_raw(&mut self, agent: &Agent, msg: &ChatMessage) {
48        match msg {
49            // System message are not shown in chat
50            ChatMessage::System(_) => (),
51
52            ChatMessage::User(UserMessage { content }) => {
53                self.push_lines(content, HistoryEntryType::User, true);
54            }
55
56            ChatMessage::Assistant(AssistantMessage {
57                reasoning_content,
58                content,
59                tool_calls,
60            }) => {
61                if let Some(reasoning) = reasoning_content {
62                    self.push_lines(reasoning, HistoryEntryType::Reasoning, true);
63                }
64                if let Some(content) = content {
65                    self.push_lines(content, HistoryEntryType::Content, true);
66                }
67                if let Some(tool_calls) = tool_calls {
68                    for call in tool_calls {
69                        self.open_tool_calls
70                            .insert(call.id.clone(), call.call.clone());
71
72                        self.push_lines(
73                            &format!("[{}] {}\n", call.id, agent.display_call(&call.call)),
74                            HistoryEntryType::ToolCall,
75                            true,
76                        );
77                    }
78                }
79            }
80
81            ChatMessage::Tool(ToolResult {
82                tool_call_id,
83                content,
84            }) => {
85                let call = self.open_tool_calls.remove(tool_call_id);
86                let name = match &call {
87                    Some(ToolCallParams::Function { function }) => &function.name,
88                    Some(ToolCallParams::Custom { custom }) => &custom.name,
89                    _ => "(unmatched call)",
90                };
91
92                match content
93                    .strip_prefix("TOOL CALL FAILED: ")
94                    .or_else(|| content.strip_prefix("TOOL CALL REJECTED: "))
95                {
96                    Some(error) => self.push_lines(
97                        &format!("=[{name}/{tool_call_id}]=> {error}\n"),
98                        HistoryEntryType::ToolResultErr,
99                        true,
100                    ),
101                    None => {
102                        let line = if let Some(call) = &call {
103                            format!(
104                                "=[{name}/{tool_call_id}]=> {}\n",
105                                agent.display_call_result(call, content)
106                            )
107                        } else {
108                            format!("=[{name}/{tool_call_id}]=> {content}\n")
109                        };
110
111                        self.push_lines(&line, HistoryEntryType::ToolResultOk, true);
112                    }
113                }
114            }
115        }
116    }
117
118    /// Return the chat history.
119    pub fn lines(&self) -> &[(String, HistoryEntryType)] {
120        &self.lines
121    }
122
123    /// Append the given string of type `ct` to the history.
124    ///
125    /// If `force_new_line` is false and the last line is of the same type, append to it; if it is
126    /// true, always create a new line.
127    pub fn push(&mut self, string: &str, kind: HistoryEntryType, force_new_line: bool) {
128        if let Some(last) = self.lines.last_mut() {
129            if last.1 == kind && !force_new_line {
130                last.0.push_str(string);
131                return;
132            } else if last.1 != kind {
133                // Convert empty lines of different type to type `Empty`, otherwise append `Empty`
134                if last.0.is_empty() {
135                    last.1 = HistoryEntryType::Empty;
136                } else {
137                    self.lines.push((String::new(), HistoryEntryType::Empty));
138                }
139            }
140        }
141
142        self.lines.push((string.to_string(), kind));
143    }
144
145    /// Append the given multi-line string to the history.
146    ///
147    /// Splits `string` at newlines and appends each line as its own entry, so that entries in
148    /// [`Self::lines()`] never span multiple physical lines.
149    ///
150    /// If `force_new_line` is false and the last line is of the same type, append to it; if it is
151    /// true, always create a new line.
152    pub fn push_lines(&mut self, string: &str, kind: HistoryEntryType, mut force_new_line: bool) {
153        for line in string.split('\n') {
154            self.push(line, kind, force_new_line);
155            force_new_line = true; // just saw \n, so next line must be on a new line
156        }
157    }
158
159    /// Force-resolve all open tool calls.
160    ///
161    /// Push an error for each tool call that is in the history that has not yet received a result.
162    /// This is useful after loading history from an existing session state, which may be
163    /// incomplete.
164    pub fn force_resolve_unresolved_tool_calls(&mut self, agent: &mut Agent) {
165        while let Some(id) = self.open_tool_calls.keys().next() {
166            let result = ToolResult::new(
167                id.clone(),
168                Err(anyhow!(
169                    "Tool call aborted due to incomplete session state, please retry"
170                )),
171            );
172            let message: ChatMessage = result.into();
173            self.push_raw(agent, &message);
174            agent.push(message);
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use llimorse::line_format::{FunctionCall, SystemMessage, ToolCall};
183
184    /// Test-only, agent-less variant of [`ChatHistory::push_raw`].
185    ///
186    /// Tool calls and results are formatted with the fallback strings the agent display uses
187    /// for unregistered tools, since no tool registry is involved.
188    impl ChatHistory {
189        fn push_raw_test(&mut self, msg: &ChatMessage) {
190            match msg {
191                ChatMessage::System(_) => (),
192
193                ChatMessage::User(UserMessage { content }) => {
194                    self.push_lines(content, HistoryEntryType::User, true);
195                }
196
197                ChatMessage::Assistant(AssistantMessage {
198                    reasoning_content,
199                    content,
200                    tool_calls,
201                }) => {
202                    if let Some(reasoning) = reasoning_content {
203                        self.push_lines(reasoning, HistoryEntryType::Reasoning, true);
204                    }
205                    if let Some(content) = content {
206                        self.push_lines(content, HistoryEntryType::Content, true);
207                    }
208                    if let Some(tool_calls) = tool_calls {
209                        for call in tool_calls {
210                            self.open_tool_calls
211                                .insert(call.id.clone(), call.call.clone());
212
213                            let display = match &call.call {
214                                ToolCallParams::Function { function } => {
215                                    format!("[unknown function {}]", function.name)
216                                }
217                                ToolCallParams::Custom { custom } => {
218                                    format!("[unknown tool {}]", custom.name)
219                                }
220                            };
221                            self.push_lines(
222                                &format!("[{}] {}\n", call.id, display),
223                                HistoryEntryType::ToolCall,
224                                true,
225                            );
226                        }
227                    }
228                }
229
230                ChatMessage::Tool(ToolResult {
231                    tool_call_id,
232                    content,
233                }) => {
234                    let call = self.open_tool_calls.remove(tool_call_id);
235                    let name = match &call {
236                        Some(ToolCallParams::Function { function }) => &function.name,
237                        Some(ToolCallParams::Custom { custom }) => &custom.name,
238                        _ => "(unmatched call)",
239                    };
240
241                    match content
242                        .strip_prefix("TOOL CALL FAILED: ")
243                        .or_else(|| content.strip_prefix("TOOL CALL REJECTED: "))
244                    {
245                        Some(error) => self.push_lines(
246                            &format!("[{name}/{tool_call_id}]=> {error}\n"),
247                            HistoryEntryType::ToolResultErr,
248                            true,
249                        ),
250                        None => {
251                            let display = match &call {
252                                Some(ToolCallParams::Function { function }) => {
253                                    format!("[unknown function {}]", function.name)
254                                }
255                                Some(ToolCallParams::Custom { custom }) => {
256                                    format!("[unknown tool {}]", custom.name)
257                                }
258                                None => String::new(),
259                            };
260                            let line = if display.is_empty() {
261                                format!("[{name}/{tool_call_id}]=> {}\n", content)
262                            } else {
263                                format!("=[{name}/{tool_call_id}]=> {}\n", display)
264                            };
265                            self.push_lines(&line, HistoryEntryType::ToolResultOk, true);
266                        }
267                    }
268                }
269            }
270        }
271    }
272
273    /// Assert the core invariant: no history entry spans multiple physical lines.
274    fn assert_single_line_entries(history: &ChatHistory) {
275        assert!(
276            !history.lines().iter().any(|(line, _)| line.contains('\n')),
277            "history entry spans multiple lines: {:#?}",
278            history.lines()
279        );
280    }
281
282    #[test]
283    fn push_raw_splits_multiline_messages() {
284        let mut history = ChatHistory::default();
285
286        history.push_raw_test(&ChatMessage::User(UserMessage {
287            content: "line one\nline two".into(),
288        }));
289        history.push_raw_test(&ChatMessage::Assistant(AssistantMessage {
290            reasoning_content: Some("think one\nthink two".into()),
291            content: Some("reply one\nreply two".into()),
292            tool_calls: None,
293        }));
294
295        assert_eq!(
296            history.lines(),
297            &[
298                ("line one".to_owned(), HistoryEntryType::User),
299                ("line two".to_owned(), HistoryEntryType::User),
300                (String::new(), HistoryEntryType::Empty),
301                ("think one".to_owned(), HistoryEntryType::Reasoning),
302                ("think two".to_owned(), HistoryEntryType::Reasoning),
303                (String::new(), HistoryEntryType::Empty),
304                ("reply one".to_owned(), HistoryEntryType::Content),
305                ("reply two".to_owned(), HistoryEntryType::Content),
306            ]
307        );
308        assert_single_line_entries(&history);
309    }
310
311    #[test]
312    fn push_raw_splits_tool_calls_and_results() {
313        let mut history = ChatHistory::default();
314
315        let call = ToolCall {
316            id: "call_1".into(),
317            call: ToolCallParams::Function {
318                function: FunctionCall {
319                    name: "some_tool".into(),
320                    arguments: "{}".into(),
321                },
322            },
323        };
324        history.push_raw_test(&ChatMessage::Assistant(AssistantMessage {
325            reasoning_content: None,
326            content: None,
327            tool_calls: Some(vec![call]),
328        }));
329        history.push_raw_test(&ChatMessage::Tool(ToolResult {
330            tool_call_id: "call_1".into(),
331            content: "result one\nresult two\n".into(),
332        }));
333
334        // The tool is not registered with the test agent, so the display falls back to the
335        // `[unknown ...]` forms
336        assert_eq!(
337            history.lines(),
338            &[
339                (
340                    "[call_1] [unknown function some_tool]".to_owned(),
341                    HistoryEntryType::ToolCall,
342                ),
343                (String::new(), HistoryEntryType::Empty),
344                (
345                    "=[some_tool/call_1]=> [unknown function some_tool]".to_owned(),
346                    HistoryEntryType::ToolResultOk,
347                ),
348                (String::new(), HistoryEntryType::ToolResultOk),
349            ]
350        );
351        assert_single_line_entries(&history);
352    }
353
354    #[test]
355    fn push_raw_does_not_merge_consecutive_messages() {
356        let mut history = ChatHistory::default();
357
358        history.push_raw_test(&ChatMessage::User(UserMessage {
359            content: "first".into(),
360        }));
361        history.push_raw_test(&ChatMessage::System(SystemMessage {
362            content: "in between".into(),
363        }));
364        history.push_raw_test(&ChatMessage::User(UserMessage {
365            content: "second".into(),
366        }));
367
368        // System messages are skipped, so the two user messages end up adjacent — but they must
369        // stay separate entries, not be merged into one line
370        assert_eq!(
371            history.lines(),
372            &[
373                ("first".to_owned(), HistoryEntryType::User),
374                ("second".to_owned(), HistoryEntryType::User),
375            ]
376        );
377        assert_single_line_entries(&history);
378    }
379}