Skip to main content

agent_engine/engine/
session.rs

1//! Engine-level session management — save, load, resume, clear.
2//!
3//! Owns the conversation state that both TUI and headless modes need:
4//! messages, token counts, cost.
5
6use crate::pricing::calculate_cost_optional_split;
7use crate::SharedMessage;
8use crate::{Runtime, Session};
9
10/// Session-scoped durability barrier state, shared by persistent frontends.
11/// A failed (or dropped in-flight) publication requires explicit reload/new
12/// session before saving or scheduling more inference. Never rollback-save an
13/// old head: `save_durable` can fail after publishing its replacement.
14#[derive(Default)]
15pub struct ContextHeadPersistence {
16    blocked_session: Option<String>,
17}
18
19impl ContextHeadPersistence {
20    pub fn is_blocked(&self, session: &Session) -> bool {
21        self.blocked_session.as_deref() == Some(session.id.as_str())
22    }
23
24    /// Candidate metadata must come from the host's current session, never
25    /// from message content. The event supplies only identity and messages.
26    pub async fn persist(
27        &mut self,
28        session: &mut Session,
29        messages: &mut Vec<SharedMessage>,
30        session_id: &str,
31        candidate: Session,
32    ) -> std::io::Result<()> {
33        self.persist_with(
34            session,
35            messages,
36            session_id,
37            candidate,
38            |candidate| async move { candidate.save_durable().await },
39        )
40        .await
41    }
42
43    async fn persist_with<F, Fut>(
44        &mut self,
45        session: &mut Session,
46        messages: &mut Vec<SharedMessage>,
47        session_id: &str,
48        candidate: Session,
49        save: F,
50    ) -> std::io::Result<()>
51    where
52        F: FnOnce(Session) -> Fut,
53        Fut: std::future::Future<Output = std::io::Result<()>>,
54    {
55        if self.is_blocked(session) {
56            return Err(std::io::Error::other(
57                "context head is unverified; reload the session before continuing",
58            ));
59        }
60        // Latch before any await, including on identity rejection. Dropping
61        // this future cannot authorize a later ordinary save of the old head.
62        self.blocked_session = Some(session.id.clone());
63        if session_id != session.id || candidate.id != session.id {
64            return Err(std::io::Error::new(
65                std::io::ErrorKind::InvalidInput,
66                "context head checkpoint does not match the current session",
67            ));
68        }
69        let result = save(candidate.clone()).await;
70        // Success adopts the durable head. An attempted-save error adopts it
71        // ONLY as conservative recovery state (not a successful commit): a
72        // rename may already have happened. The latch remains set, so neither
73        // post-turn saves nor automatic inference can act on this ambiguity.
74        *messages = candidate.api_messages.clone();
75        *session = candidate;
76        if result.is_ok() {
77            self.blocked_session = None;
78        }
79        result
80    }
81}
82
83/// Conversation state tracked by the engine.
84pub struct ConversationState {
85    pub session: Session,
86    pub context_head: ContextHeadPersistence,
87    pub api_messages: Vec<SharedMessage>,
88    pub total_input_tokens: u64,
89    pub total_output_tokens: u64,
90    pub total_cache_read_tokens: u64,
91    pub total_cache_creation_tokens: u64,
92    pub session_cost: f64,
93    /// Prompt-cache keep-warm spend (task #444), already INCLUDED in
94    /// `session_cost` and shown separately; persisted with it
95    /// (`Session::keepwarm_cost`).
96    pub keepwarm_cost: f64,
97    /// Keep-warm ping tokens, already INCLUDED in the `total_*` counters
98    /// above and shown separately so `/stats` adds up.
99    pub keepwarm_tokens: crate::session::ConversationTokens,
100    /// Message queued to send after current stream completes.
101    pub queued_message: Option<String>,
102    /// Events buffered during streaming — drained on stream completion.
103    pub pending_events: Vec<String>,
104}
105
106/// The keep-warm token subtotal as persisted in the snapshot
107/// (`Session::keepwarm_tokens`, snapshot-only — never journaled).
108fn persisted_keepwarm_tokens(
109    t: &crate::session::ConversationTokens,
110) -> agent_core::core::session::KeepwarmTokens {
111    agent_core::core::session::KeepwarmTokens {
112        input: t.input,
113        output: t.output,
114        cache_read: t.cache_read,
115        cache_creation: t.cache_creation,
116    }
117}
118
119/// The keep-warm subtotal a resumed conversation starts with: each bucket
120/// clamped under the total it is part of, so `/stats` still adds up. The
121/// cache totals persist with the session (snapshot-only); a session saved
122/// before they did resumes them at 0, and its keep-warm cache buckets too.
123fn resumed_keepwarm_tokens(session: &Session) -> crate::session::ConversationTokens {
124    let kw = session.keepwarm_tokens;
125    crate::session::ConversationTokens {
126        input: kw.input.min(session.total_input_tokens),
127        output: kw.output.min(session.total_output_tokens),
128        cache_read: kw.cache_read.min(session.total_cache_read_tokens),
129        cache_creation: kw.cache_creation.min(session.total_cache_creation_tokens),
130    }
131}
132
133impl ConversationState {
134    /// Create a new conversation with a fresh session.
135    pub fn new(session: Session) -> Self {
136        Self {
137            session,
138            context_head: ContextHeadPersistence::default(),
139            api_messages: Vec::new(),
140            total_input_tokens: 0,
141            total_output_tokens: 0,
142            total_cache_read_tokens: 0,
143            total_cache_creation_tokens: 0,
144            session_cost: 0.0,
145            keepwarm_cost: 0.0,
146            keepwarm_tokens: Default::default(),
147            queued_message: None,
148            pending_events: Vec::new(),
149        }
150    }
151
152    /// Create from a resumed session. A session saved before interruption
153    /// markers existed (legacy `abort_context` recap) is migrated here too —
154    /// `/resume` loads sessions without going through `engine::setup` —
155    /// idempotently: an already-migrated session is untouched.
156    pub fn from_resumed(mut session: Session) -> Self {
157        crate::engine::interrupt::migrate_legacy_abort_context(
158            &mut session.api_messages,
159            &mut session.abort_context,
160        );
161        Self {
162            api_messages: session.api_messages.clone(),
163            total_input_tokens: session.total_input_tokens,
164            total_output_tokens: session.total_output_tokens,
165            total_cache_read_tokens: session.total_cache_read_tokens,
166            total_cache_creation_tokens: session.total_cache_creation_tokens,
167            session_cost: session.session_cost,
168            keepwarm_cost: session.keepwarm_cost.min(session.session_cost).max(0.0),
169            keepwarm_tokens: resumed_keepwarm_tokens(&session),
170            queued_message: None,
171            pending_events: Vec::new(),
172            session,
173            context_head: ContextHeadPersistence::default(),
174        }
175    }
176
177    /// Save the current conversation state to disk. Returns whether it was
178    /// written: `false` when there is nothing to save (empty history), the
179    /// context head is unverified (`context_head`), or the write failed
180    /// (logged).
181    pub async fn save(&mut self) -> bool {
182        let Some(session) = self.prepare_save() else {
183            return false;
184        };
185        match session.save().await {
186            Ok(()) => true,
187            Err(e) => {
188                tracing::error!("Failed to save session: {}", e);
189                false
190            }
191        }
192    }
193
194    /// Fold the live state into `self.session` and return the snapshot to
195    /// write — or `None` when there is nothing to save (empty history) or
196    /// the context head is unverified. The session actor hands the snapshot
197    /// to its background writer (`session::persister`) instead of awaiting
198    /// the I/O. The clone is cheap: messages are `Arc`-shared.
199    pub fn prepare_save(&mut self) -> Option<Session> {
200        if self.context_head.is_blocked(&self.session) || self.api_messages.is_empty() {
201            return None;
202        }
203        self.session.api_messages = self.api_messages.clone();
204        self.session.total_input_tokens = self.total_input_tokens;
205        self.session.total_output_tokens = self.total_output_tokens;
206        self.session.total_cache_read_tokens = self.total_cache_read_tokens;
207        self.session.total_cache_creation_tokens = self.total_cache_creation_tokens;
208        self.session.session_cost = self.session_cost;
209        self.session.keepwarm_cost = self.keepwarm_cost;
210        self.session.keepwarm_tokens = persisted_keepwarm_tokens(&self.keepwarm_tokens);
211        // Legacy field: an interrupted turn now lives in `api_messages`
212        // (`engine::interrupt`); never write a recap back.
213        self.session.abort_context = None;
214        self.session.updated_at = chrono::Utc::now();
215        self.session.auto_title();
216        Some(self.session.clone())
217    }
218
219    /// Persist a runtime-requested head using host metadata and latest usage.
220    /// The caller completes the receipt with this exact result and continues
221    /// consuming the runtime's typed terminal error on failure.
222    pub async fn persist_context_head(
223        &mut self,
224        session_id: &str,
225        messages: Vec<SharedMessage>,
226    ) -> std::io::Result<()> {
227        let mut candidate = self.session.clone();
228        candidate.api_messages = messages;
229        candidate.total_input_tokens = self.total_input_tokens;
230        candidate.total_output_tokens = self.total_output_tokens;
231        candidate.total_cache_read_tokens = self.total_cache_read_tokens;
232        candidate.total_cache_creation_tokens = self.total_cache_creation_tokens;
233        candidate.session_cost = self.session_cost;
234        candidate.keepwarm_cost = self.keepwarm_cost;
235        candidate.keepwarm_tokens = persisted_keepwarm_tokens(&self.keepwarm_tokens);
236        candidate.abort_context = None;
237        candidate.updated_at = chrono::Utc::now();
238        candidate.auto_title();
239        self.context_head
240            .persist(
241                &mut self.session,
242                &mut self.api_messages,
243                session_id,
244                candidate,
245            )
246            .await
247    }
248
249    /// Clear the current session and start fresh.
250    pub async fn clear(&mut self, runtime: &Runtime) {
251        self.save().await;
252        self.context_head = ContextHeadPersistence::default();
253        self.api_messages.clear();
254        self.total_input_tokens = 0;
255        self.total_output_tokens = 0;
256        self.total_cache_read_tokens = 0;
257        self.total_cache_creation_tokens = 0;
258        self.session_cost = 0.0;
259        self.keepwarm_cost = 0.0;
260        self.keepwarm_tokens = Default::default();
261        self.queued_message = None;
262        self.pending_events.clear();
263        self.session = Session::new(
264            runtime.model(),
265            runtime.thinking_level(),
266            runtime.system_prompt(),
267        );
268        runtime.reset_context_continuation(&self.session.id, &[]);
269    }
270
271    /// Serializable mirror for clients (`SessionEventWire::Conversation`).
272    /// `consecutive_auto_turns` is actor/App-side state, not conversation
273    /// state, so the caller supplies it.
274    pub fn snapshot(
275        &self,
276        consecutive_auto_turns: u32,
277    ) -> crate::session::ConversationSnapshot {
278        crate::session::ConversationSnapshot {
279            header: crate::session::SessionHeader::from(&self.session),
280            api_messages: self.api_messages.clone(),
281            messages_len: self.api_messages.len(),
282            tokens: crate::session::ConversationTokens {
283                input: self.total_input_tokens,
284                output: self.total_output_tokens,
285                cache_read: self.total_cache_read_tokens,
286                cache_creation: self.total_cache_creation_tokens,
287            },
288            cost: self.session_cost,
289            keepwarm_cost: self.keepwarm_cost,
290            keepwarm_tokens: self.keepwarm_tokens.clone(),
291            // Wire-compat field (protocol v3); always `None` now.
292            abort_context: None,
293            queued_message: self.queued_message.clone(),
294            pending_events_len: self.pending_events.len(),
295            consecutive_auto_turns,
296            messages_hash_memo: Default::default(),
297        }
298    }
299
300    /// Add usage from a model turn.
301    ///
302    /// `cache_creation_5m` / `cache_creation_1h` are the cache-write TTL
303    /// split. When either is present the cost uses the split rates
304    /// (5m: 1.25×, 1h: 2.0×); when both are `None` the aggregate
305    /// `cache_creation` is billed at the 5m rate (fail-cheap fallback).
306    #[allow(clippy::too_many_arguments)]
307    pub fn add_usage(
308        &mut self,
309        input_tokens: u64,
310        output_tokens: u64,
311        cache_read: u64,
312        cache_creation: u64,
313        cache_creation_5m: Option<u64>,
314        cache_creation_1h: Option<u64>,
315        model: &str,
316    ) {
317        self.total_input_tokens += input_tokens;
318        self.total_output_tokens += output_tokens;
319        self.total_cache_read_tokens += cache_read;
320        self.total_cache_creation_tokens += cache_creation;
321
322        self.session_cost += calculate_cost_optional_split(
323            model,
324            input_tokens,
325            output_tokens,
326            cache_read,
327            cache_creation,
328            cache_creation_5m,
329            cache_creation_1h,
330        );
331    }
332}
333
334#[cfg(test)]
335mod context_head_tests {
336    use super::*;
337    use agent_core::core::context_head::ContextHeadReceipt;
338    #[cfg(unix)]
339    use agent_core::core::session_journal::{save_session_durable_in_dir, SessionPersistence};
340    use serde_json::json;
341    use std::sync::Arc;
342
343    fn fixture() -> (Session, Vec<SharedMessage>, Session) {
344        let mut old = Session::new("synthetic-model", "medium", Some("host authority"));
345        old.id = "synthetic-context-head".into();
346        old.name = Some("synthetic-name".into());
347        old.title = "original title".into();
348        old.parent_session = Some("synthetic-parent".into());
349        old.api_messages = vec![Arc::new(json!({"role":"user","content":"old head"}))];
350        let messages = old.api_messages.clone();
351        let mut candidate = old.clone();
352        candidate.api_messages = vec![Arc::new(json!({"role":"user","content":"candidate"}))];
353        candidate.total_input_tokens = 23;
354        candidate.total_output_tokens = 7;
355        candidate.session_cost = 0.125;
356        candidate.abort_context = Some("latest synthetic abort context".into());
357        (old, messages, candidate)
358    }
359
360    #[cfg(unix)]
361    #[tokio::test]
362    async fn context_head_ack_follows_durable_write_and_adoption() {
363        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
364            let dir = tempfile::tempdir().unwrap();
365            let (mut session, mut messages, candidate) = fixture();
366            let id = session.id.clone();
367            let expected = serde_json::to_value(&candidate).unwrap();
368            let mut state = ContextHeadPersistence::default();
369            let (receipt, mut acknowledged) = ContextHeadReceipt::channel();
370            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
371            let (release_tx, release_rx) = tokio::sync::oneshot::channel();
372            let path = dir.path().to_owned();
373            let result = {
374                let persist = state.persist_with(
375                    &mut session,
376                    &mut messages,
377                    &id,
378                    candidate,
379                    |candidate| async move {
380                        started_tx.send(()).unwrap();
381                        release_rx.await.unwrap();
382                        save_session_durable_in_dir(&path, &candidate, mode).map(|_| ())
383                    },
384                );
385                tokio::pin!(persist);
386                tokio::select! {
387                    result = &mut persist => panic!("write unexpectedly completed: {result:?}"),
388                    _ = started_rx => {}
389                }
390                assert!(matches!(
391                    acknowledged.try_recv(),
392                    Err(tokio::sync::oneshot::error::TryRecvError::Empty)
393                ));
394                release_tx.send(()).unwrap();
395                persist.await
396            };
397            assert!(result.is_ok());
398            receipt.complete(result);
399            assert_eq!(acknowledged.await.unwrap(), Ok(()));
400            assert_eq!(serde_json::to_value(&session).unwrap(), expected);
401            assert_eq!(messages, session.api_messages);
402            assert!(!state.is_blocked(&session));
403            let loaded = Session::load_from_dir(dir.path(), &id).unwrap();
404            assert_eq!(loaded.api_messages, messages);
405            assert_eq!(loaded.system_prompt.as_deref(), Some("host authority"));
406            assert_eq!(loaded.name, session.name);
407            assert_eq!(loaded.total_input_tokens, 23);
408            assert_eq!(loaded.total_output_tokens, 7);
409            assert_eq!(loaded.abort_context, session.abort_context);
410        }
411    }
412
413    #[tokio::test]
414    async fn context_head_identity_mismatch_never_writes_or_adopts() {
415        let (mut session, mut messages, candidate) = fixture();
416        let before = serde_json::to_value(&session).unwrap();
417        let mut state = ContextHeadPersistence::default();
418        let (receipt, acknowledged) = ContextHeadReceipt::channel();
419        let result = state
420            .persist_with(
421                &mut session,
422                &mut messages,
423                "another-session",
424                candidate,
425                |_| async { panic!("identity rejection must not reach persistence") },
426            )
427            .await;
428        assert_eq!(
429            result.as_ref().unwrap_err().kind(),
430            std::io::ErrorKind::InvalidInput
431        );
432        receipt.complete(result);
433        assert!(acknowledged.await.unwrap().is_err());
434        assert_eq!(serde_json::to_value(&session).unwrap(), before);
435        assert_eq!(messages, session.api_messages);
436        assert!(state.is_blocked(&session));
437        let mut other = session.clone();
438        other.id = "new-session".into();
439        assert!(!state.is_blocked(&other));
440    }
441
442    /// The cache totals persist with the session, so a daemon reload, an
443    /// unpark or `/resume` keeps the footer's cache hit rate. The keep-warm
444    /// cache buckets come back clamped under the totals they are part of.
445    #[test]
446    fn cache_totals_survive_save_and_resume() {
447        let mut s = Session::new("synthetic-model", "low", None);
448        s.api_messages = vec![Arc::new(json!({"role":"user","content":"q"}))];
449        let mut conv = ConversationState::from_resumed(s);
450        conv.add_usage(120, 40, 85_679, 6_250, None, None, "synthetic-model");
451        conv.keepwarm_tokens.cache_read = 4_000;
452        conv.keepwarm_tokens.cache_creation = 9_999; // stale label, above its total
453        let saved = conv.prepare_save().expect("non-empty history saves");
454        assert_eq!(saved.total_cache_read_tokens, 85_679);
455        assert_eq!(saved.total_cache_creation_tokens, 6_250);
456
457        let resumed = ConversationState::from_resumed(saved);
458        assert_eq!(resumed.total_cache_read_tokens, 85_679);
459        assert_eq!(resumed.total_cache_creation_tokens, 6_250);
460        assert_eq!(resumed.keepwarm_tokens.cache_read, 4_000);
461        assert_eq!(resumed.keepwarm_tokens.cache_creation, 6_250);
462        let wire = resumed.snapshot(0);
463        assert_eq!(wire.tokens.cache_read, 85_679);
464        assert_eq!(wire.tokens.cache_creation, 6_250);
465    }
466
467    /// `/resume` builds its conversation with `from_resumed` directly: a
468    /// legacy recap is migrated (dropped, marker appended), never carried.
469    #[test]
470    fn from_resumed_migrates_a_legacy_abort_context() {
471        let mut legacy = Session::new("synthetic-model", "low", None);
472        legacy.api_messages = vec![Arc::new(json!({"role":"user","content":"do X"}))];
473        legacy.abort_context = Some("(System note — ABORT CONTEXT: …)".into());
474        let conv = ConversationState::from_resumed(legacy);
475        assert!(conv.session.abort_context.is_none());
476        assert_eq!(conv.api_messages.len(), 2);
477        assert_eq!(
478            conv.api_messages[1]["content"],
479            crate::engine::interrupt::InterruptReason::Unknown.marker()
480        );
481        assert_eq!(conv.session.api_messages, conv.api_messages);
482        // Idempotent for an already-migrated session.
483        let again = ConversationState::from_resumed(conv.session.clone());
484        assert_eq!(again.api_messages, conv.api_messages);
485    }
486
487    #[cfg(unix)]
488    #[tokio::test]
489    async fn context_head_post_publish_error_is_not_rollback_or_success() {
490        let dir = tempfile::tempdir().unwrap();
491        let (session, _, candidate) = fixture();
492        let mut conv = ConversationState::from_resumed(session);
493        let id = conv.session.id.clone();
494        let expected = candidate.api_messages.clone();
495        let path = dir.path().to_owned();
496        let (receipt, acknowledged) = ContextHeadReceipt::channel();
497        let result = conv
498            .context_head
499            .persist_with(
500                &mut conv.session,
501                &mut conv.api_messages,
502                &id,
503                candidate,
504                |candidate| async move {
505                    save_session_durable_in_dir(&path, &candidate, SessionPersistence::Journal)?;
506                    Err(std::io::Error::other("synthetic error after publication"))
507                },
508            )
509            .await;
510        receipt.complete(result);
511        assert!(acknowledged.await.unwrap().is_err());
512        assert!(conv.context_head.is_blocked(&conv.session));
513        assert_eq!(conv.api_messages, expected);
514        assert_eq!(
515            Session::load_from_dir(dir.path(), &id)
516                .unwrap()
517                .api_messages,
518            expected
519        );
520        // Simulate stale failure repair/UI data. save() must return before it
521        // even copies this old state into the conservatively adopted Session.
522        conv.api_messages.clear();
523        conv.api_messages
524            .push(Arc::new(json!({"role":"user","content":"stale"})));
525        conv.save().await;
526        assert_eq!(conv.session.api_messages, expected);
527        let loaded = Session::load_from_dir(dir.path(), &id).unwrap();
528        let resumed = ConversationState::from_resumed(loaded);
529        assert!(!resumed.context_head.is_blocked(&resumed.session));
530    }
531
532    #[tokio::test]
533    async fn context_head_dropped_write_future_latches_without_mutating_old_head() {
534        let (mut session, mut messages, candidate) = fixture();
535        let before = serde_json::to_value(&session).unwrap();
536        let id = session.id.clone();
537        let mut state = ContextHeadPersistence::default();
538        {
539            let persist = state.persist_with(&mut session, &mut messages, &id, candidate, |_| {
540                std::future::pending::<std::io::Result<()>>()
541            });
542            tokio::pin!(persist);
543            assert!(futures::poll!(&mut persist).is_pending());
544        }
545        assert!(state.is_blocked(&session));
546        assert_eq!(serde_json::to_value(&session).unwrap(), before);
547        assert_eq!(messages, session.api_messages);
548    }
549}