Skip to main content

llama_cpp_v3_agent_sdk/
conversation.rs

1use crate::tool::{ToolCall, ToolResult};
2use serde::{Deserialize, Serialize};
3
4/// A single message in the conversation.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Message {
7    pub role: Role,
8    pub content: String,
9    /// If this message is an assistant message that contained tool calls,
10    /// they are recorded here for context tracking.
11    #[serde(skip_serializing_if = "Vec::is_empty", default)]
12    pub tool_calls: Vec<ToolCall>,
13    /// If this message is a tool result, the originating call is recorded here.
14    #[serde(skip_serializing_if = "Option::is_none", default)]
15    pub tool_result: Option<ToolCallWithResult>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(rename_all = "lowercase")]
20pub enum Role {
21    System,
22    User,
23    Assistant,
24    Tool,
25}
26
27impl Role {
28    pub fn as_str(&self) -> &str {
29        match self {
30            Role::System => "system",
31            Role::User => "user",
32            Role::Assistant => "assistant",
33            Role::Tool => "tool",
34        }
35    }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ToolCallWithResult {
40    pub call: ToolCall,
41    pub result: ToolResult,
42}
43
44/// Manages the full conversation history.
45pub struct Conversation {
46    messages: Vec<Message>,
47}
48
49impl Conversation {
50    pub fn new() -> Self {
51        Self {
52            messages: Vec::new(),
53        }
54    }
55
56    /// Create a new conversation with a system prompt.
57    pub fn with_system_prompt(system_prompt: &str) -> Self {
58        let mut conv = Self::new();
59        conv.add_system(system_prompt);
60        conv
61    }
62
63    /// Add a system message.
64    pub fn add_system(&mut self, content: &str) {
65        self.messages.push(Message {
66            role: Role::System,
67            content: content.to_string(),
68            tool_calls: vec![],
69            tool_result: None,
70        });
71    }
72
73    /// Add a user message.
74    pub fn add_user(&mut self, content: &str) {
75        self.messages.push(Message {
76            role: Role::User,
77            content: content.to_string(),
78            tool_calls: vec![],
79            tool_result: None,
80        });
81    }
82
83    /// Add an assistant message (model output).
84    pub fn add_assistant(&mut self, content: &str, tool_calls: Vec<ToolCall>) {
85        self.messages.push(Message {
86            role: Role::Assistant,
87            content: content.to_string(),
88            tool_calls,
89            tool_result: None,
90        });
91    }
92
93    /// Add a tool result message.
94    pub fn add_tool_result(&mut self, call: ToolCall, result: ToolResult) {
95        let content = if result.success {
96            format!("[Tool: {}] {}", call.name, result.output)
97        } else {
98            format!("[Tool: {} ERROR] {}", call.name, result.output)
99        };
100        self.messages.push(Message {
101            role: Role::Tool,
102            content,
103            tool_calls: vec![],
104            tool_result: Some(ToolCallWithResult { call, result }),
105        });
106    }
107
108    /// Convert conversation to `ChatMessage` slice for the llama-cpp-v3 template engine.
109    pub fn to_chat_messages(&self) -> Vec<llama_cpp_v3::ChatMessage> {
110        self.messages
111            .iter()
112            .map(|m| llama_cpp_v3::ChatMessage {
113                role: m.role.as_str().to_string(),
114                content: m.content.clone(),
115            })
116            .collect()
117    }
118
119    /// Get all messages.
120    pub fn messages(&self) -> &[Message] {
121        &self.messages
122    }
123
124    /// Clear all messages.
125    pub fn clear(&mut self) {
126        self.messages.clear();
127    }
128
129    /// Number of messages in the conversation.
130    pub fn len(&self) -> usize {
131        self.messages.len()
132    }
133
134    pub fn is_empty(&self) -> bool {
135        self.messages.is_empty()
136    }
137
138    /// Compact the conversation by summarizing older messages.
139    ///
140    /// Keeps the system prompt and the last `keep_recent` messages,
141    /// replacing everything in between with a summary message.
142    pub fn compact(&mut self, summary: &str, keep_recent: usize) {
143        if self.messages.len() <= keep_recent + 1 {
144            return; // nothing to compact
145        }
146
147        let system_msg = if !self.messages.is_empty() && self.messages[0].role == Role::System {
148            Some(self.messages[0].clone())
149        } else {
150            None
151        };
152
153        let total = self.messages.len();
154        let start = if system_msg.is_some() { 1 } else { 0 };
155        let keep_from = if total > keep_recent {
156            total - keep_recent
157        } else {
158            start
159        };
160
161        // Adjust to a safe cut point (never split a tool call from its result)
162        let keep_from = self.find_safe_cut_point(keep_from);
163
164        let recent: Vec<Message> = self.messages[keep_from..].to_vec();
165
166        self.messages.clear();
167
168        if let Some(sys) = system_msg {
169            self.messages.push(sys);
170        }
171
172        // Insert the compacted summary as a system message
173        self.messages.push(Message {
174            role: Role::System,
175            content: format!("[Conversation Summary]\n{}", summary),
176            tool_calls: vec![],
177            tool_result: None,
178        });
179
180        self.messages.extend(recent);
181    }
182
183    /// Find a safe cut point at or before `target_idx`.
184    ///
185    /// A safe cut point is a turn boundary where we don't split an assistant
186    /// message from its following tool-result messages. We walk backward from
187    /// `target_idx` to find the start of a complete turn.
188    pub fn find_safe_cut_point(&self, target_idx: usize) -> usize {
189        let start = if !self.messages.is_empty() && self.messages[0].role == Role::System {
190            1
191        } else {
192            0
193        };
194
195        if target_idx <= start {
196            return start;
197        }
198
199        let mut idx = target_idx.min(self.messages.len());
200
201        // Walk backward to find a point that's NOT in the middle of a
202        // tool-call → tool-result pair.
203        while idx > start {
204            let msg = &self.messages[idx.saturating_sub(1)];
205            // If the message just before the cut is a Tool result, keep going
206            // back to include the assistant message that triggered it.
207            if msg.role == Role::Tool {
208                idx -= 1;
209            } else if msg.role == Role::Assistant && !msg.tool_calls.is_empty() {
210                // The assistant has tool calls — we need to include the
211                // tool results that follow it, so cut before this message.
212                idx -= 1;
213                if idx <= start {
214                    break;
215                }
216            } else {
217                break;
218            }
219        }
220
221        idx.max(start)
222    }
223
224    /// Serialize messages in a range to a human-readable string for
225    /// summarization by the model.
226    pub fn serialize_range(&self, from: usize, to: usize) -> String {
227        let mut lines = Vec::new();
228        for msg in &self.messages[from..to] {
229            let role = match msg.role {
230                Role::System => "System",
231                Role::User => "User",
232                Role::Assistant => "Assistant",
233                Role::Tool => "Tool",
234            };
235            lines.push(format!("[{}]: {}", role, msg.content));
236        }
237        lines.join("\n\n")
238    }
239
240    /// Count of messages that would be compacted (everything between system
241    /// prompt and the last `keep_recent` messages).
242    pub fn compactable_count(&self, keep_recent: usize) -> usize {
243        let start = if !self.messages.is_empty() && self.messages[0].role == Role::System {
244            1
245        } else {
246            0
247        };
248        let total = self.messages.len();
249        if total <= keep_recent + start {
250            0
251        } else {
252            total - keep_recent - start
253        }
254    }
255}
256
257impl Default for Conversation {
258    fn default() -> Self {
259        Self::new()
260    }
261}