Skip to main content

qcli/
conversation.rs

1//! A conversation with the model, kept in the Messages shape it is sent in.
2//!
3//! The messages are kept exactly as the API reads them, so what is saved is what was said. What a
4//! request sends is a *view* of them: a long conversation goes out trimmed to a size, with a note
5//! standing in where something was left out. Nothing kept is translated on the way.
6
7use std::collections::HashMap;
8use std::ops::Range;
9
10use serde_json::{Value, json};
11
12/// A tool call the model made.
13#[derive(Debug, Clone, PartialEq)]
14pub struct Call {
15    /// The id the result must name.
16    pub id: String,
17    /// The tool.
18    pub name: String,
19    /// What it was given.
20    pub input: Value,
21}
22
23/// The messages of one conversation.
24#[derive(Debug, Clone, PartialEq, Default)]
25pub struct Conversation {
26    messages: Vec<Value>,
27}
28
29impl Conversation {
30    /// The messages, oldest first.
31    #[must_use]
32    pub fn messages(&self) -> &[Value] {
33        &self.messages
34    }
35
36    /// The messages as they go out in a request that is at most `budget` bytes: the conversation
37    /// as it is when it fits, and a trimmed view of it when it does not. What is kept is kept
38    /// whole, and nothing is asked of the model to make the view smaller.
39    ///
40    /// Three parts are never shortened: the first message, which usually holds the task; the
41    /// newest message, which holds the tool results the model is waiting for an answer to; and
42    /// the shape of a call and its result, so that every call still has its answer. What is
43    /// shortened first is the tool results of the turns in between, oldest first, since they are
44    /// the biggest part of an old turn and the least needed of it; the text of those turns
45    /// follows in the same order, only if the results alone are not enough. Wherever something
46    /// is left out, an English note stands in its place saying what was left out, and the
47    /// conversation kept here is not touched.
48    #[must_use]
49    pub fn view(&self, budget: usize) -> Vec<Value> {
50        let mut view = self.messages.clone();
51        if bytes(&view) <= budget {
52            return view;
53        }
54        let names = tool_names(&self.messages);
55        let old = 1..view.len().saturating_sub(1);
56        if shorten(&mut view, &old, budget, "tool_result", &names) {
57            return view;
58        }
59        shorten(&mut view, &old, budget, "text", &names);
60        view
61    }
62
63    /// Whether nothing has been said yet.
64    #[must_use]
65    pub fn is_empty(&self) -> bool {
66        self.messages.is_empty()
67    }
68
69    /// Adds what the person wrote. Right after another user message (a reply that was stopped
70    /// before it said anything) the text joins that message, since the API wants the roles to
71    /// take turns.
72    pub fn say(&mut self, text: &str) {
73        let block = json!({"type": "text", "text": text});
74        self.push_user(vec![block]);
75    }
76
77    /// Adds the model's reply. Nothing is added for a reply with no content.
78    pub fn reply(&mut self, content: Vec<Value>) {
79        if !content.is_empty() {
80            self.messages.push(json!({"role": "assistant", "content": Value::Array(content)}));
81        }
82    }
83
84    /// Adds the results of tool calls, each a `tool_result` block.
85    pub fn results(&mut self, results: Vec<Value>) {
86        if !results.is_empty() {
87            self.push_user(results);
88        }
89    }
90
91    fn push_user(&mut self, blocks: Vec<Value>) {
92        if let Some(last) = self.messages.last_mut()
93            && last["role"] == "user"
94            && let Some(content) = last.get_mut("content").and_then(Value::as_array_mut)
95        {
96            content.extend(blocks);
97            return;
98        }
99        self.messages.push(json!({"role": "user", "content": blocks}));
100    }
101
102    /// The tool calls of the last message that have no result yet, in the order they were made.
103    #[must_use]
104    pub fn open_calls(&self) -> Vec<Call> {
105        let Some(last) = self.messages.last().filter(|message| message["role"] == "assistant") else {
106            return Vec::new();
107        };
108        blocks(last)
109            .filter(|block| block["type"] == "tool_use")
110            .map(|block| Call {
111                id: block["id"].as_str().unwrap_or_default().to_owned(),
112                name: block["name"].as_str().unwrap_or_default().to_owned(),
113                input: block.get("input").cloned().unwrap_or(Value::Null),
114            })
115            .collect()
116    }
117
118    /// The conversation as it is saved.
119    #[must_use]
120    pub fn to_json(&self) -> Value {
121        json!({"version": 1, "messages": self.messages})
122    }
123
124    /// Whether `value` is a conversation as this program keeps one. A file that is not one holds
125    /// somebody's thread in a shape this program cannot show, which is not the same as a file
126    /// that holds nothing.
127    #[must_use]
128    pub fn holds(value: &Value) -> bool {
129        value["messages"].is_array()
130    }
131
132    /// A saved conversation. Anything that is not a message is left out, and calls that were
133    /// waiting when the program closed are answered as not done, so the conversation can go on.
134    #[must_use]
135    pub fn from_json(value: &Value) -> Self {
136        let messages = value["messages"]
137            .as_array()
138            .map(|messages| {
139                messages
140                    .iter()
141                    .filter(|message| {
142                        matches!(message["role"].as_str(), Some("user" | "assistant")) && message["content"].is_array()
143                    })
144                    .cloned()
145                    .collect()
146            })
147            .unwrap_or_default();
148        let mut conversation = Self { messages: mend(messages) };
149        let open = conversation.open_calls();
150        conversation.results(open.iter().map(|call| result(&call.id, CLOSED, true)).collect());
151        conversation
152    }
153}
154
155/// `messages` as the API accepts them, however an older program or a crash left them: every
156/// call's input is an object, and every call in the middle of the thread has its result first
157/// thing in the message after it, where a result that was never written is added as not done.
158/// Nothing that was said is taken out. The calls of the last message are left to the caller,
159/// which answers them with its own sentence.
160fn mend(messages: Vec<Value>) -> Vec<Value> {
161    let mut mended: Vec<Value> = Vec::with_capacity(messages.len());
162    let mut waiting: Vec<String> = Vec::new();
163    for mut message in messages {
164        if !waiting.is_empty() {
165            let mut calls = std::mem::take(&mut waiting);
166            if message["role"] == "user" {
167                // Each result already there answers one call of its id.
168                for block in blocks(&message).filter(|block| block["type"] == "tool_result") {
169                    let id = block["tool_use_id"].as_str().unwrap_or_default();
170                    if let Some(at) = calls.iter().position(|call| call == id) {
171                        calls.remove(at);
172                    }
173                }
174            }
175            let answers: Vec<Value> = calls.iter().map(|id| result(id, CLOSED, true)).collect();
176            if answers.is_empty() {
177            } else if message["role"] == "user"
178                && let Some(content) = message.get_mut("content").and_then(Value::as_array_mut)
179            {
180                content.splice(0..0, answers);
181            } else {
182                mended.push(json!({"role": "user", "content": answers}));
183            }
184        }
185        if message["role"] == "assistant" {
186            waiting = calls_of(&mut message);
187        }
188        mended.push(message);
189    }
190    mended
191}
192
193/// The ids of the calls in `message`, with each call's input made an object on the way.
194fn calls_of(message: &mut Value) -> Vec<String> {
195    let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) else { return Vec::new() };
196    let mut ids = Vec::new();
197    for block in content.iter_mut().filter(|block| block["type"] == "tool_use") {
198        if !block["input"].is_object() {
199            let kept = match &block["input"] {
200                Value::String(text) => text.clone(),
201                Value::Null => String::new(),
202                other => other.to_string(),
203            };
204            block["input"] = if kept.is_empty() { json!({}) } else { json!({ crate::provider::UNPARSED: kept }) };
205        }
206        ids.push(block["id"].as_str().unwrap_or_default().to_owned());
207    }
208    ids
209}
210
211/// What a call that was waiting when the program closed is answered with.
212const CLOSED: &str = "Not done: the program was closed before this ran.";
213
214/// The blocks of `message`.
215pub fn blocks(message: &Value) -> impl Iterator<Item = &Value> {
216    message["content"].as_array().into_iter().flatten()
217}
218
219/// A `tool_result` block for the call `id`.
220#[must_use]
221pub fn result(id: &str, text: &str, is_error: bool) -> Value {
222    json!({"type": "tool_result", "tool_use_id": id, "content": text, "is_error": is_error})
223}
224
225/// How many bytes `messages` takes in a request.
226fn bytes(messages: &[Value]) -> usize {
227    // Counted as written rather than written into a string: a long conversation is megabytes of
228    // JSON, and the view measures it more than once per request.
229    struct Count(usize);
230    impl std::io::Write for Count {
231        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
232            self.0 += buf.len();
233            Ok(buf.len())
234        }
235        fn flush(&mut self) -> std::io::Result<()> {
236            Ok(())
237        }
238    }
239    let mut count = Count(0);
240    serde_json::to_writer(&mut count, messages).map_or(0, |()| count.0)
241}
242
243/// What each call was called, by its id: a note can then name the tool whose output it left out,
244/// which is read rather than guessed.
245fn tool_names(messages: &[Value]) -> HashMap<&str, &str> {
246    messages
247        .iter()
248        .flat_map(blocks)
249        .filter(|block| block["type"] == "tool_use")
250        .filter_map(|block| Some((block["id"].as_str()?, block["name"].as_str()?)))
251        .collect()
252}
253
254/// Stands `block` in for itself, with a note in place of its text, when it is of type `wanted`
255/// and too big to go out whole. `None` when there is nothing to gain, so a block that is already
256/// small is left alone rather than made bigger.
257fn note_for(block: &Value, wanted: &str, names: &HashMap<&str, &str>) -> Option<Value> {
258    if block["type"].as_str()? != wanted {
259        return None;
260    }
261    let (field, what) = match wanted {
262        "tool_result" => {
263            let id = block["tool_use_id"].as_str().unwrap_or_default();
264            ("content", format!("output from {}", names.get(id).copied().unwrap_or("an earlier call")))
265        }
266        "text" => ("text", "earlier text".to_owned()),
267        _ => return None,
268    };
269    let mut note = block.clone();
270    note[field] = Value::String(format!(
271        "(Left out: {} bytes of {what}. The whole of it is in the conversation saved for this folder.)",
272        block[field].as_str()?.len()
273    ));
274    (bytes(std::slice::from_ref(&note)) < bytes(std::slice::from_ref(block))).then_some(note)
275}
276
277/// Replaces the blocks of type `wanted` in the messages `old` holds, oldest first, until `view`
278/// fits `budget` or there is nothing left that can be shortened. `true` when it fits.
279fn shorten(view: &mut [Value], old: &Range<usize>, budget: usize, wanted: &str, names: &HashMap<&str, &str>) -> bool {
280    let mut over = bytes(view).saturating_sub(budget);
281    for message in &mut view[old.clone()] {
282        if over == 0 {
283            return true;
284        }
285        let Some(blocks) = message.get_mut("content").and_then(Value::as_array_mut) else { continue };
286        for block in blocks.iter_mut() {
287            if over == 0 {
288                return true;
289            }
290            let Some(note) = note_for(block, wanted, names) else { continue };
291            over = over.saturating_sub(bytes(std::slice::from_ref(block)) - bytes(std::slice::from_ref(&note)));
292            *block = note;
293        }
294    }
295    over == 0
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn roles_take_turns_even_after_a_reply_that_said_nothing() {
304        let mut conversation = Conversation::default();
305        conversation.say("first");
306        conversation.reply(Vec::new());
307        conversation.say("second");
308        assert_eq!(conversation.messages().len(), 1, "the two texts share one user message");
309        assert_eq!(conversation.messages()[0]["content"][1]["text"], "second");
310        conversation.reply(vec![json!({"type": "text", "text": "answer"})]);
311        assert_eq!(conversation.messages()[1]["role"], "assistant");
312    }
313
314    #[test]
315    fn open_calls_are_the_last_replys_tool_uses() {
316        let mut conversation = Conversation::default();
317        conversation.say("look");
318        conversation.reply(vec![
319            json!({"type": "text", "text": "Looking."}),
320            json!({"type": "tool_use", "id": "a", "name": "list_dir", "input": {"path": "."}}),
321            json!({"type": "tool_use", "id": "b", "name": "read_file", "input": {"path": "x"}}),
322        ]);
323        let open = conversation.open_calls();
324        assert_eq!(open.iter().map(|call| call.id.as_str()).collect::<Vec<_>>(), ["a", "b"]);
325        assert_eq!(open[1].input, json!({"path": "x"}));
326        conversation.results(vec![result("a", "x/", false), result("b", "text", false)]);
327        assert!(conversation.open_calls().is_empty());
328    }
329
330    #[test]
331    fn a_saved_conversation_comes_back_and_waiting_calls_are_answered() {
332        let mut conversation = Conversation::default();
333        conversation.say("run it");
334        conversation
335            .reply(vec![json!({"type": "tool_use", "id": "r", "name": "run_command", "input": {"command": "ls"}})]);
336        let saved = conversation.to_json();
337        let back = Conversation::from_json(&saved);
338        assert!(back.open_calls().is_empty(), "the waiting call is answered");
339        let last = back.messages().last().expect("a message");
340        assert_eq!(last["content"][0]["tool_use_id"], "r");
341        assert_eq!(last["content"][0]["is_error"], true);
342        assert_eq!(Conversation::from_json(&json!({"messages": [{"role": "system"}, 3]})), Conversation::default());
343    }
344
345    #[test]
346    fn a_message_whose_content_is_not_a_list_is_left_out_and_the_rest_stays() {
347        let back = Conversation::from_json(&json!({"messages": [
348            {"role": "user", "content": [{"type": "text", "text": "kept"}]},
349            {"role": "assistant", "content": 7},
350            {"role": "user", "content": "a plain string"},
351            {"role": "user", "content": [{"type": "text", "text": "kept too"}]},
352        ]}));
353        let texts: Vec<_> = back.messages().iter().map(|message| message["content"][0]["text"].clone()).collect();
354        assert_eq!(texts, [json!("kept"), json!("kept too")], "the two good messages, in order");
355    }
356
357    #[test]
358    fn a_call_with_no_id_gets_one_and_never_panics_on_a_missing_field() {
359        let mut conversation = Conversation::default();
360        conversation.reply(vec![
361            json!({"type": "tool_use", "name": "list_dir", "input": {"path": "."}}),
362            json!({"type": "tool_use", "id": 5, "name": null}),
363        ]);
364        let open = conversation.open_calls();
365        assert_eq!(open.len(), 2, "both blocks are calls to be taken");
366        assert_eq!(open[0].id, "", "a call that names no id gets none");
367        assert_eq!(open[1].name, "", "nor a call that names no tool");
368        assert_eq!(open[1].input, Value::Null, "and no input either");
369
370        // The same blocks coming back from a file are answered, empty id and all.
371        let back = Conversation::from_json(&conversation.to_json());
372        let answered: Vec<_> = blocks(back.messages().last().expect("the results"))
373            .map(|block| block["tool_use_id"].as_str().unwrap_or_default().to_owned())
374            .collect();
375        assert_eq!(answered, ["", ""], "both are answered, so the next request is one the API accepts");
376    }
377
378    #[test]
379    fn two_calls_with_one_id_are_answered_one_for_one() {
380        let back = Conversation::from_json(&json!({"messages": [
381            {"role": "assistant", "content": [
382                {"type": "tool_use", "id": "same", "name": "list_dir", "input": {}},
383                {"type": "tool_use", "id": "same", "name": "read_file", "input": {}},
384            ]},
385        ]}));
386        assert!(back.open_calls().is_empty());
387        let answered: Vec<_> = blocks(back.messages().last().expect("the results"))
388            .map(|block| block["tool_use_id"].as_str().unwrap_or_default().to_owned())
389            .collect();
390        assert_eq!(answered, ["same", "same"], "both calls are answered, each in its own place");
391    }
392
393    #[test]
394    fn a_result_of_a_call_that_is_not_there_is_kept_and_the_call_it_left_waiting_is_answered() {
395        let back = Conversation::from_json(&json!({"messages": [
396            {"role": "assistant", "content": [{"type": "tool_use", "id": "here", "name": "list_dir", "input": {}}]},
397            {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "elsewhere", "content": "stray"}]},
398        ]}));
399        // The call of the file had no result, which the API refuses on every request; it is
400        // answered first thing in the message after it, and what the file said stays behind it.
401        assert_eq!(back.messages().len(), 2);
402        let last = back.messages().last().expect("a message");
403        let said: Vec<_> = blocks(last)
404            .map(|block| {
405                (block["tool_use_id"].as_str().unwrap_or_default(), block["content"].as_str().unwrap_or_default())
406            })
407            .collect();
408        assert_eq!(said, [("here", CLOSED), ("elsewhere", "stray")]);
409    }
410
411    #[test]
412    fn a_call_answered_in_the_file_is_not_answered_twice() {
413        let saved = json!({"messages": [
414            {"role": "user", "content": [{"type": "text", "text": "look"}]},
415            {"role": "assistant", "content": [{"type": "tool_use", "id": "a", "name": "list_dir", "input": {}}]},
416            {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "a", "content": "x/"}]},
417        ]});
418        let back = Conversation::from_json(&saved);
419        assert_eq!(
420            back.messages(),
421            saved["messages"].as_array().expect("messages").as_slice(),
422            "a whole thread is kept as it is"
423        );
424    }
425}
426
427#[cfg(test)]
428mod view_tests {
429    use super::*;
430
431    /// A conversation of `turns` turns, each an answer of `answer` bytes and a result of
432    /// `result` bytes, after the task that started it.
433    fn long(answer: usize, result: usize, turns: usize) -> Conversation {
434        let mut conversation = Conversation::default();
435        conversation.say("the task");
436        for n in 0..turns {
437            let id = format!("c{n}");
438            conversation.reply(vec![
439                json!({"type": "text", "text": "a".repeat(answer)}),
440                json!({"type": "tool_use", "id": id, "name": "read_file", "input": {"path": "a.rs"}}),
441            ]);
442            conversation.results(vec![result_of(&id, &"x".repeat(result))]);
443        }
444        conversation
445    }
446
447    /// A `tool_result` block for the call `id`, holding `text`.
448    fn result_of(id: &str, text: &str) -> Value {
449        super::result(id, text, false)
450    }
451
452    /// What each result of a view holds, in the order they go out.
453    fn results(view: &[Value]) -> Vec<String> {
454        view.iter()
455            .flat_map(blocks)
456            .filter(|block| block["type"] == "tool_result")
457            .map(|block| block["content"].as_str().unwrap_or_default().to_owned())
458            .collect()
459    }
460
461    /// Whether `text` is a note standing in for what was left out.
462    fn left_out(text: &str) -> bool {
463        text.starts_with("(Left out:")
464    }
465
466    #[test]
467    fn a_conversation_under_the_budget_is_sent_whole() {
468        let conversation = long(7, 100, 3);
469        let budget = bytes(conversation.messages());
470        assert_eq!(conversation.view(budget), conversation.messages());
471        assert_eq!(conversation.view(budget + 1), conversation.messages());
472    }
473
474    #[test]
475    fn the_oldest_results_are_left_out_first_and_the_newest_turn_is_whole() {
476        let conversation = long(7, 4_000, 4);
477        let view = conversation.view(5_500);
478        assert!(bytes(&view) <= 5_500, "{} bytes", bytes(&view));
479
480        let sent = results(&view);
481        assert_eq!(sent.len(), 4, "no result is dropped, only shortened");
482        assert!(sent[..3].iter().all(|text| left_out(text)), "the oldest three go first: {sent:?}");
483        assert!(sent[3..].iter().all(|text| text.len() == 4_000), "and what still fits stays whole: {sent:?}");
484
485        let note = &sent[0];
486        assert!(note.starts_with("(Left out: 4000 bytes of output from read_file."), "{note}");
487        assert!(note.ends_with("The whole of it is in the conversation saved for this folder.)"), "{note}");
488        assert_eq!(view.first(), conversation.messages().first(), "the first message is never touched");
489    }
490
491    #[test]
492    fn a_result_the_model_waits_for_keeps_its_answer_and_its_call() {
493        let view = long(7, 4_000, 4).view(1_000);
494        for wanted in ["tool_use", "tool_result"] {
495            let found = view.iter().flat_map(blocks).filter(|block| block["type"] == wanted).count();
496            assert_eq!(found, 4, "every {wanted} is still there");
497        }
498        for call in view.iter().flat_map(blocks).filter(|block| block["type"] == "tool_use") {
499            let id = call["id"].as_str().expect("an id");
500            let answered =
501                view.iter().flat_map(blocks).any(|block| block["type"] == "tool_result" && block["tool_use_id"] == id);
502            assert!(answered, "{id} is still answered");
503        }
504    }
505
506    #[test]
507    fn a_result_too_small_to_gain_from_a_note_is_left_whole() {
508        let conversation = long(7, 20, 3);
509        let sent = results(&conversation.view(0));
510        assert!(sent.iter().all(|text| !left_out(text)), "{sent:?}: a note would be bigger than the result");
511    }
512
513    #[test]
514    fn old_text_is_shortened_when_there_is_nothing_to_gain_in_the_results() {
515        let conversation = long(4_000, 20, 4);
516        let view = conversation.view(5_500);
517        assert!(bytes(&view) <= 5_500, "{} bytes", bytes(&view));
518
519        let answers: Vec<&str> = view
520            .iter()
521            .filter(|message| message["role"] == "assistant")
522            .flat_map(blocks)
523            .filter(|block| block["type"] == "text")
524            .map(|block| block["text"].as_str().unwrap_or_default())
525            .collect();
526        assert_eq!(answers.len(), 4, "no text is dropped, only shortened");
527        assert!(answers[..3].iter().all(|text| left_out(text)), "the oldest three go first: {answers:?}");
528        assert_eq!(answers[3].len(), 4_000, "and the newest answer is whole");
529        for note in &answers[..3] {
530            assert!(note.contains("4000 bytes of earlier text"), "{note}");
531        }
532    }
533
534    #[test]
535    fn a_thinking_block_is_never_touched() {
536        let mut conversation = long(4_000, 20, 3);
537        conversation.reply(vec![json!({"type": "thinking", "thinking": "z".repeat(4_000), "signature": "sig"})]);
538        let view = conversation.view(1_000);
539        assert!(bytes(&view) > 1_000, "what cannot be shortened goes out as it is: {} bytes", bytes(&view));
540        let thinking = view.iter().flat_map(blocks).find(|block| block["type"] == "thinking").expect("the thinking");
541        assert_eq!(thinking["thinking"], json!("z".repeat(4_000)), "it is whole");
542        assert_eq!(thinking["signature"], json!("sig"), "and its signature is not broken");
543    }
544
545    #[test]
546    fn what_cannot_be_shortened_goes_out_whole_even_past_the_budget() {
547        let conversation = long(7, 4_000, 3);
548        let view = conversation.view(500);
549        assert!(bytes(&view) > 500, "the newest result alone is over the budget: {} bytes", bytes(&view));
550        assert_eq!(view.last(), conversation.messages().last());
551    }
552
553    #[test]
554    fn the_stored_conversation_is_never_changed_by_a_view() {
555        let conversation = long(7, 4_000, 4);
556        let kept = conversation.clone();
557        let _ = conversation.view(500);
558        assert_eq!(conversation, kept);
559    }
560}