Skip to main content

oino_ai/
transcript.rs

1//! System-message replay and the
2//! `diffSystemPromptSections` patch helper.
3//!
4//! A transcript carries the prompt and tool loadout in system messages: the first declares the
5//! base state; later ones patch `sections` by name (`null` removes) and list
6//! `toolsAdded`/`toolsRemoved`. Replaying them in order yields the current state.
7
8use indexmap::IndexMap;
9
10use crate::content::UserTextField;
11use crate::message::{Message, Sections, SystemMessage, ToolDecl, ToolRef};
12use crate::primitives::TimestampMs;
13use crate::stream::Context;
14
15/// Replayed prompt/tool state.
16#[derive(Clone, Debug, Default, PartialEq)]
17pub struct SystemState {
18    /// Non-empty contents joined with a blank line.
19    pub content: String,
20    /// Live sections in insertion order.
21    pub sections: IndexMap<String, String>,
22    /// Live tools in declaration order.
23    pub tools: Vec<ToolDecl>,
24    /// Timestamp of the first system message, if any.
25    pub first_timestamp: Option<TimestampMs>,
26}
27
28impl SystemState {
29    /// Folds system messages in order (`getCurrentSystemMessage` internals).
30    pub fn fold<'a>(systems: impl IntoIterator<Item = &'a SystemMessage>) -> Self {
31        let mut content = Vec::new();
32        let mut sections = IndexMap::new();
33        let mut tools: IndexMap<String, ToolDecl> = IndexMap::new();
34        let mut first_timestamp = None;
35        for message in systems {
36            first_timestamp.get_or_insert(message.timestamp);
37            let text = message.content.text("\n");
38            if !text.is_empty() {
39                content.push(text);
40            }
41            for (name, value) in message.sections.iter().flatten() {
42                match value {
43                    Some(text) => {
44                        sections.insert(name.clone(), text.clone());
45                    }
46                    None => {
47                        sections.shift_remove(name);
48                    }
49                }
50            }
51            for removed in message.tools_removed.iter().flatten() {
52                tools.shift_remove(&removed.name);
53            }
54            for added in message.tools_added.iter().flatten() {
55                tools.insert(added.name.clone(), added.clone());
56            }
57        }
58        Self {
59            content: content.join("\n\n"),
60            sections,
61            tools: tools.into_values().collect(),
62            first_timestamp,
63        }
64    }
65
66    /// The equivalent single system message, or `None` when no system message existed and no
67    /// tool is live (returns `undefined`).
68    #[must_use]
69    pub fn into_message(self) -> Option<SystemMessage> {
70        if self.first_timestamp.is_none() && self.tools.is_empty() {
71            return None;
72        }
73        Some(SystemMessage {
74            content: UserTextField::Text(self.content),
75            sections: (!self.sections.is_empty()).then(|| {
76                self.sections
77                    .into_iter()
78                    .map(|(name, text)| (name, Some(text)))
79                    .collect()
80            }),
81            tools_added: (!self.tools.is_empty()).then_some(self.tools),
82            tools_removed: None,
83            timestamp: self.first_timestamp.unwrap_or(0),
84        })
85    }
86}
87
88fn systems(messages: &[Message]) -> impl Iterator<Item = &SystemMessage> {
89    messages.iter().filter_map(|message| match message {
90        Message::System(system) => Some(system),
91        _ => None,
92    })
93}
94
95/// Replayed state of all system messages in `messages`.
96#[must_use]
97pub fn current_system(messages: &[Message]) -> SystemState {
98    SystemState::fold(systems(messages))
99}
100
101/// Returns the current system message.
102#[must_use]
103pub fn current_system_message(messages: &[Message]) -> Option<SystemMessage> {
104    current_system(messages).into_message()
105}
106
107/// Returns the current tools.
108#[must_use]
109pub fn current_tools(messages: &[Message]) -> Vec<ToolDecl> {
110    current_system(messages).tools
111}
112
113/// Every tool ever declared (latest definition per name).
114#[must_use]
115pub fn declared_tools(messages: &[Message]) -> Vec<ToolDecl> {
116    let mut definitions: IndexMap<String, ToolDecl> = IndexMap::new();
117    for added in systems(messages).flat_map(|m| m.tools_added.iter().flatten()) {
118        definitions.insert(added.name.clone(), added.clone());
119    }
120    definitions.into_values().collect()
121}
122
123/// Content plus live section texts joined with blank lines.
124#[must_use]
125pub fn system_message_text(message: &SystemMessage) -> String {
126    std::iter::once(message.content.text("\n"))
127        .chain(
128            message
129                .sections
130                .iter()
131                .flatten()
132                .filter_map(|(_, text)| text.clone()),
133        )
134        .filter(|part| !part.is_empty())
135        .collect::<Vec<_>>()
136        .join("\n\n")
137}
138
139/// Human-readable rendering of a system patch.
140#[must_use]
141pub fn render_system_message_update(message: &SystemMessage) -> String {
142    let mut parts = Vec::new();
143    let text = message.content.text("\n");
144    if !text.is_empty() {
145        parts.push(text);
146    }
147    for (name, value) in message.sections.iter().flatten() {
148        parts.push(match value {
149            None => format!("Removed system prompt section \"{name}\"."),
150            Some(value) => format!("Updated system prompt section \"{name}\":\n\n{value}"),
151        });
152    }
153    parts.join("\n\n")
154}
155
156/// Changed/added sections with their text,
157/// then removed sections as `null`; `None` when nothing changed.
158#[must_use]
159pub fn diff_sections(
160    previous: &IndexMap<String, String>,
161    current: &IndexMap<String, String>,
162) -> Option<Sections> {
163    let mut patch = Sections::new();
164    for (name, text) in current {
165        if previous.get(name) != Some(text) {
166            patch.insert(name.clone(), Some(text.clone()));
167        }
168    }
169    for name in previous.keys() {
170        if !current.contains_key(name) {
171            patch.insert(name.clone(), None);
172        }
173    }
174    (!patch.is_empty()).then_some(patch)
175}
176
177/// A leading system message for a prompt and tools, or
178/// `None` when both are empty.
179#[must_use]
180pub fn initial_system_message(system_prompt: &str, tools: &[ToolDecl]) -> Option<SystemMessage> {
181    if system_prompt.is_empty() && tools.is_empty() {
182        return None;
183    }
184    Some(SystemMessage {
185        content: UserTextField::Text(system_prompt.to_owned()),
186        sections: None,
187        tools_added: (!tools.is_empty()).then(|| tools.to_vec()),
188        tools_removed: None,
189        timestamp: 0,
190    })
191}
192
193/// One leading folded system message, other messages unchanged.
194#[must_use]
195pub fn collapse_system_messages(context: Context) -> Context {
196    let head = current_system_message(&context.messages);
197    let rest = context
198        .messages
199        .into_iter()
200        .filter(|message| !matches!(message, Message::System(_)));
201    Context {
202        messages: head.map(Message::System).into_iter().chain(rest).collect(),
203    }
204}
205
206/// How much mid-conversation system support a provider has.
207#[derive(Clone, Copy, Debug, PartialEq, Eq)]
208pub enum ToolChangeSupport {
209    /// Tools are only sent as request-level declarations.
210    None,
211    /// Tool changes can be expressed inline.
212    Changes,
213    /// Additions inline; removals/redefinitions force folding.
214    Additive,
215}
216
217/// Result of [`resolve_transcript_tools`].
218#[derive(Clone, Debug, Default, PartialEq)]
219pub struct ResolvedTools {
220    /// Tools to declare at request level.
221    pub request_tools: Vec<ToolDecl>,
222    /// Number of leading system messages folded into `request_tools`.
223    pub folded_system_messages: usize,
224}
225
226/// R e s o l v e   T r a n s c r i p t   T o o l s.
227#[must_use]
228pub fn resolve_transcript_tools(messages: &[Message], support: ToolChangeSupport) -> ResolvedTools {
229    let system_messages: Vec<&SystemMessage> = systems(messages).collect();
230    match support {
231        ToolChangeSupport::None => ResolvedTools {
232            request_tools: SystemState::fold(system_messages.iter().copied()).tools,
233            folded_system_messages: system_messages.len(),
234        },
235        ToolChangeSupport::Changes => ResolvedTools::default(),
236        ToolChangeSupport::Additive => {
237            let leading = usize::from(matches!(messages.first(), Some(Message::System(_))));
238            let folded =
239                leading.max(last_non_additive_change(&system_messages).map_or(0, |i| i + 1));
240            ResolvedTools {
241                request_tools: SystemState::fold(system_messages[..folded].iter().copied()).tools,
242                folded_system_messages: folded,
243            }
244        }
245    }
246}
247
248fn last_non_additive_change(system_messages: &[&SystemMessage]) -> Option<usize> {
249    let mut declared = std::collections::HashSet::new();
250    let mut last = None;
251    for (index, message) in system_messages.iter().enumerate() {
252        if message
253            .tools_removed
254            .as_ref()
255            .is_some_and(|removed| !removed.is_empty())
256        {
257            last = Some(index);
258        }
259        for tool in message.tools_added.iter().flatten() {
260            if !declared.insert(tool.name.as_str()) {
261                last = Some(index);
262            }
263        }
264    }
265    last
266}
267
268/// Returns whether non additive tool changes is present.
269#[must_use]
270pub fn has_non_additive_tool_changes(messages: &[Message]) -> bool {
271    last_non_additive_change(&systems(messages).collect::<Vec<_>>()).is_some()
272}
273
274/// Tools to add (new or changed) and remove.
275#[must_use]
276pub fn tool_state_changes(
277    previous: &[ToolDecl],
278    current: &[ToolDecl],
279) -> (Vec<ToolDecl>, Vec<ToolRef>) {
280    let find = |tools: &[ToolDecl], name: &str| tools.iter().find(|t| t.name == name).cloned();
281    let added = current
282        .iter()
283        .filter(|tool| find(previous, &tool.name).as_ref() != Some(*tool))
284        .cloned()
285        .collect();
286    let removed = previous
287        .iter()
288        .filter(|tool| find(current, &tool.name).as_ref() != Some(*tool))
289        .map(|tool| ToolRef {
290            name: tool.name.clone(),
291        })
292        .collect();
293    (added, removed)
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::message::UserMessage;
300    use serde_json::json;
301
302    fn system(raw: serde_json::Value) -> Message {
303        serde_json::from_value(raw).unwrap_or(Message::User(UserMessage::default()))
304    }
305
306    #[test]
307    fn replay_patches_sections_and_tools() {
308        let messages = vec![
309            system(
310                json!({"role":"system","content":"base","sections":{"a":"A","b":"B"},
311                "toolsAdded":[{"name":"read","description":"r","parameters":{}},{"name":"write","description":"w","parameters":{}}],"timestamp":5}),
312            ),
313            Message::User(UserMessage::default()),
314            system(
315                json!({"role":"system","content":"","sections":{"a":null,"c":"C","b":"B2"},"toolsRemoved":[{"name":"read"}],"timestamp":9}),
316            ),
317        ];
318        let current = current_system_message(&messages);
319        assert_eq!(
320            current.and_then(|m| serde_json::to_value(Message::System(m)).ok()),
321            Some(
322                json!({"role":"system","content":"base","sections":{"b":"B2","c":"C"},
323                "toolsAdded":[{"name":"write","description":"w","parameters":{}}],"timestamp":5})
324            )
325        );
326        assert!(has_non_additive_tool_changes(&messages));
327        let resolved = resolve_transcript_tools(&messages, ToolChangeSupport::Additive);
328        assert_eq!(resolved.folded_system_messages, 2);
329        assert_eq!(
330            collapse_system_messages(Context { messages })
331                .messages
332                .len(),
333            2
334        );
335    }
336
337    #[test]
338    fn no_system_messages_yields_none() {
339        assert_eq!(
340            current_system_message(&[Message::User(UserMessage::default())]),
341            None
342        );
343    }
344
345    #[test]
346    fn diff_sections_matches_pi() {
347        let previous: IndexMap<String, String> = [("a", "1"), ("b", "2")]
348            .map(|(k, v)| (k.to_owned(), v.to_owned()))
349            .into_iter()
350            .collect();
351        let current: IndexMap<String, String> = [("b", "3"), ("c", "4")]
352            .map(|(k, v)| (k.to_owned(), v.to_owned()))
353            .into_iter()
354            .collect();
355        let patch = diff_sections(&previous, &current);
356        assert_eq!(
357            serde_json::to_value(patch).ok(),
358            Some(json!({"b":"3","c":"4","a":null}))
359        );
360        assert_eq!(diff_sections(&current, &current), None);
361    }
362}