Skip to main content

pawan/agent/
session.rs

1//! Agent session and conversation history management.
2//!
3//! Re-exports persistence types from [`session_store`] so `crate::agent::session::*`
4//! remains stable.
5
6pub use super::session_store::*;
7
8use super::{
9    fence_external_system_messages_for_resume, Message, PawanAgent, Role,
10};
11use crate::config::PawanConfig;
12use crate::tools::ToolDefinition;
13use crate::Result;
14
15impl PawanAgent {
16    pub fn history(&self) -> &[Message] {
17        &self.history
18    }
19
20    /// Save current conversation as a session, returns session ID
21    pub fn save_session(&self) -> Result<String> {
22        let mut session = Session::new(&self.config.model);
23        session.messages = self.history.clone();
24        session.total_tokens = self.context_tokens_estimate as u64;
25        session.save()?;
26        Ok(session.id)
27    }
28
29    /// Resume a saved session by ID
30    pub fn resume_session(&mut self, session_id: &str) -> Result<()> {
31        let session = Session::load(session_id)?;
32        self.history = session.messages;
33        self.context_tokens_estimate = session.total_tokens as usize;
34        // Adopt the loaded session's id so eruka writes cluster under the
35        // same key as the on-disk session.
36        self.session_id = session_id.to_string();
37        fence_external_system_messages_for_resume(&mut self.history);
38        Ok(())
39    }
40
41    /// Archive the current conversation to Eruka's context store. Safe to
42    /// call from any async context; returns Ok even when eruka is disabled
43    /// or unreachable so callers can fire-and-forget after save_session().
44    pub async fn archive_to_eruka(&self) -> Result<()> {
45        let Some(eruka) = &self.eruka else {
46            return Ok(());
47        };
48        let mut session = Session::new(&self.config.model);
49        session.id = self.session_id.clone();
50        session.messages = self.history.clone();
51        session.total_tokens = self.context_tokens_estimate as u64;
52        eruka.archive_session(&session).await
53    }
54
55    /// Build a compact snapshot of the current history for on_pre_compress.
56    /// Keeps message role + first 200 chars per entry so the eruka write
57    /// stays bounded even with huge histories.
58    pub(crate) fn history_snapshot_for_eruka(history: &[Message]) -> String {
59        let mut out = String::with_capacity(2048);
60        for msg in history {
61            let prefix = match msg.role {
62                Role::User => "U: ",
63                Role::Assistant => "A: ",
64                Role::Tool => "T: ",
65                Role::System => "S: ",
66            };
67            let body: String = msg.content.chars().take(200).collect();
68            out.push_str(prefix);
69            out.push_str(&body);
70            out.push('\n');
71            if out.len() > 4000 {
72                break;
73            }
74        }
75        out
76    }
77
78    /// Get the configuration
79    pub fn config(&self) -> &PawanConfig {
80        &self.config
81    }
82
83    /// Clear the conversation history
84    pub fn clear_history(&mut self) {
85        self.history.clear();
86    }
87
88    /// Prune conversation history to reduce context size.
89    /// Uses importance scoring (inspired by claude-code-rust's consolidation engine):
90    /// - Tool results with errors: high importance (learning from failures)
91    /// - User messages: medium importance (intent context)
92    /// - Successful tool results: low importance (can be re-derived)
93    ///
94    /// Keeps system prompt + last 4 messages, summarizes the rest.
95    pub(crate) fn prune_history(&mut self) {
96        let len = self.history.len();
97        if len <= 5 {
98            return; // Nothing to prune
99        }
100
101        let keep_end = 4;
102        let start = 1; // Skip system prompt at index 0
103        let end = len - keep_end;
104        let pruned_count = end - start;
105
106        // Score messages by importance for summary prioritization
107        let mut scored: Vec<(f32, &Message)> = self.history[start..end]
108            .iter()
109            .map(|msg| {
110                let score = Self::message_importance(msg);
111                (score, msg)
112            })
113            .collect();
114        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
115
116        // Build summary from highest-importance messages first (UTF-8 safe)
117        let mut summary = String::with_capacity(2048);
118        for (score, msg) in &scored {
119            let prefix = match msg.role {
120                Role::User => "User: ",
121                Role::Assistant => "Assistant: ",
122                Role::Tool => {
123                    if *score > 0.7 {
124                        "Tool error: "
125                    } else {
126                        "Tool: "
127                    }
128                }
129                Role::System => "System: ",
130            };
131            let chunk: String = msg.content.chars().take(200).collect();
132            summary.push_str(prefix);
133            summary.push_str(&chunk);
134            summary.push('\n');
135            if summary.len() > 2000 {
136                let safe_end = summary
137                    .char_indices()
138                    .take_while(|(i, _)| *i <= 2000)
139                    .last()
140                    .map(|(i, c)| i + c.len_utf8())
141                    .unwrap_or(0);
142                summary.truncate(safe_end);
143                break;
144            }
145        }
146
147        let summary_msg = Message {
148            role: Role::System,
149            content: format!(
150                "Previous conversation summary (pruned {} messages, importance-ranked): {}",
151                pruned_count, summary
152            ),
153            tool_calls: vec![],
154            tool_result: None,
155        };
156
157        self.history.drain(start..end);
158        self.history.insert(start, summary_msg);
159
160        tracing::info!(
161            pruned = pruned_count,
162            context_estimate = self.context_tokens_estimate,
163            "Pruned messages from history (importance-ranked)"
164        );
165    }
166
167    /// Score a message's importance for pruning decisions (0.0-1.0).
168    /// Higher = more important = kept in summary.
169    pub(crate) fn message_importance(msg: &Message) -> f32 {
170        match msg.role {
171            Role::User => 0.6,   // User intent is moderately important
172            Role::System => 0.3, // System messages are usually ephemeral
173            Role::Assistant => {
174                if msg.content.contains("error") || msg.content.contains("Error") {
175                    0.8
176                } else {
177                    0.4
178                }
179            }
180            Role::Tool => {
181                if let Some(ref result) = msg.tool_result {
182                    if !result.success {
183                        0.9
184                    }
185                    // Failed tools are very important (learning)
186                    else {
187                        0.2
188                    } // Successful tools can be re-derived
189                } else {
190                    0.3
191                }
192            }
193        }
194    }
195
196    /// Add a message to history
197    pub fn add_message(&mut self, message: Message) {
198        self.history.push(message);
199    }
200
201    /// Switch the LLM model at runtime. Recreates the backend with the new model.
202    pub fn switch_model(&mut self, model: &str) -> Result<()> {
203        self.config.model = model.to_string();
204        let system_prompt = self.config.get_system_prompt_checked()?;
205        self.backend = Self::create_backend(&self.config, &system_prompt);
206        tracing::info!(model = model, "Model switched at runtime");
207        Ok(())
208    }
209
210    /// Get the current model name
211    pub fn model_name(&self) -> &str {
212        &self.config.model
213    }
214
215    /// Stable session id for this agent (Eruka sync, persistence keys)
216    pub fn session_id(&self) -> &str {
217        &self.session_id
218    }
219
220    /// Get tool definitions for the LLM
221    pub fn get_tool_definitions(&self) -> Vec<ToolDefinition> {
222        self.tools.get_definitions()
223    }
224}