Skip to main content

runtime/
conversation.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The run's conversation history: the volatile half of a request.
7//!
8//! Frozen-prefix bytes never live here. What does is every message the
9//! run has exchanged since it started, folded forward turn by turn by
10//! the executor that owns it, and handed to the model beneath the
11//! prefix on each call.
12//!
13//! **One invariant, enforced at every entrance**: consecutive user
14//! content joins the message already open rather than opening a second
15//! one. A steer, a tool result and the opening task are three doors into
16//! the same rule, so none of them can produce the alternating shape a
17//! provider refuses.
18//!
19//! **A message already sent is never rewritten.** Text that arrives after
20//! the last assembly, while the open user message is on the wire, is held
21//! and lands after the next tool results, so the next request extends the
22//! last one instead of editing it. The one exception is tool results after
23//! an empty reply, which extend the sent message rather than open a second
24//! adjacent user message, and leave it open again (`crates/runtime/spec/Conversation.lean` §8-47).
25
26use kernel::{ChatMessage, ContentBlock, Role};
27
28/// How the first user message opens: kernel's, because `run_started`
29/// records it (`crates/kernel/Spec.lean` §8-82-1).
30pub use kernel::event::record::Opening;
31
32/// The run's conversation history, owned by the executor and folded
33/// forward turn by turn. Frozen-prefix bytes never live here — the
34/// conversation is the volatile half of the request.
35#[derive(Debug, Clone, Default)]
36pub struct Conversation {
37    messages: Vec<ChatMessage>,
38    /// How many of `messages` the last assembly sent.
39    sent: usize,
40    /// User text that arrived while the open user message was already
41    /// sent; it waits for the next tool results.
42    held: Vec<ContentBlock>,
43}
44
45impl Conversation {
46    pub fn new() -> Conversation {
47        Conversation::default()
48    }
49
50    /// The dispatch lines: deterministic from `run_started`'s recorded
51    /// inputs, hence rebuildable.
52    ///
53    /// The job file's text is the run segment of the frozen prefix, so
54    /// the assigned opening names it rather than repeating it: the run
55    /// segment is not cached, and a pasted task written here again
56    /// would be paid for twice on every turn.
57    pub fn push_task_lines(&mut self, task: &str, goal: &str, opening: Opening) {
58        self.push_user_text(match opening {
59            Opening::FromJob => format!("The task is in JOB.md above.\nGoal: {goal}"),
60            Opening::Inherited => format!("Task: {task}\nGoal: {goal}"),
61            // The person's own line, unwrapped. A conversational turn
62            // dressed in field labels reads as a form, and a form is
63            // answered with a form.
64            Opening::WithPerson => task.to_owned(),
65        });
66    }
67
68    /// Steer joins the tail of the open user message, or opens one. A
69    /// steer that arrives after that message was sent is held until the
70    /// next tool results, and rides after them.
71    pub fn push_steer(&mut self, source: &str, text: &str) {
72        self.push_user_text(format!("{source}: {text}"));
73    }
74
75    /// The context reminder takes the same door as a steer, so it lands
76    /// where a steer lands: at the end of the tool results the model
77    /// reads next.
78    pub fn push_reminder(&mut self, reminder: &crate::reminder::ContextReminder) {
79        self.push_user_text(reminder.render());
80    }
81
82    /// What a branch starts from: the messages the mother exchanged, in
83    /// the order she exchanged them.
84    ///
85    /// **Verbatim, and that is not laziness.** The list being pushed was
86    /// folded through this same type, one turn at a time, so it already
87    /// satisfies the invariant below; re-deciding here whether its last
88    /// message may join the next would be a second answer to a rule this
89    /// module owns. The caller pushes the new run's own lines afterwards,
90    /// and those do join.
91    pub fn push_inherited(&mut self, messages: &[ChatMessage]) {
92        self.messages.extend_from_slice(messages);
93    }
94
95    pub fn push_assistant(&mut self, content: Vec<ContentBlock>) {
96        if !content.is_empty() {
97            self.messages.push(ChatMessage {
98                role: Role::Assistant,
99                content,
100            });
101        }
102    }
103
104    /// Tool results join the last user message, or open the next one,
105    /// and the text held since the last assembly follows them.
106    ///
107    /// Results never wait in `held`: a reply with no content pushes no
108    /// assistant message, so the last user message may already be sent,
109    /// and holding the results there would hold them at every turn. The
110    /// message they extend is open again, so a steer before the next
111    /// assembly joins it after them.
112    pub fn push_tool_results(&mut self, mut results: Vec<ContentBlock>) {
113        results.append(&mut self.held);
114        if results.is_empty() {
115            return;
116        }
117        let reopened = self.messages.len().saturating_sub(1);
118        match self.messages.last_mut() {
119            Some(last) if last.role == Role::User => {
120                last.content.extend(results);
121                self.sent = self.sent.min(reopened);
122            }
123            _ => self.messages.push(ChatMessage {
124                role: Role::User,
125                content: results,
126            }),
127        }
128    }
129
130    /// Everything in `messages()` is now on the wire: later user text
131    /// joins none of it.
132    pub fn mark_sent(&mut self) {
133        self.sent = self.messages.len();
134    }
135
136    pub fn messages(&self) -> &[ChatMessage] {
137        &self.messages
138    }
139
140    fn push_user_text(&mut self, text: String) {
141        self.push_user(vec![ContentBlock::Text { text }]);
142    }
143
144    fn push_user(&mut self, blocks: Vec<ContentBlock>) {
145        if blocks.is_empty() {
146            return;
147        }
148        let open = self.messages.len() > self.sent;
149        match self.messages.last_mut() {
150            Some(last) if last.role == Role::User && open => last.content.extend(blocks),
151            Some(last) if last.role == Role::User => self.held.extend(blocks),
152            _ => self.messages.push(ChatMessage {
153                role: Role::User,
154                content: blocks,
155            }),
156        }
157    }
158}