Skip to main content

pawan/agent/
session.rs

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