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}