Skip to main content

machi_runtime/
session.rs

1//! Multi-turn session helper over [`TurnRuntime`].
2//!
3//! # Conversation state model (canonical)
4//!
5//! - **Turn-local buffer:** [`VecConversationState`] implements
6//!   [`ConversationState`] for the synchronous sample/tool loop.
7//! - **Session source of truth (optional):** [`ChatStateHandle`] is the
8//!   multi-turn durable surface. Prefer
9//!   [`Session::run_turn_on_handle`] when you need actor isolation,
10//!   usage ledger, or checkpointing via [`machi_state::ChatPersistence`].
11//!
12//! Do not treat the two as parallel “equal” product APIs: handle is the
13//! session store; `VecConversationState` is the turn engine buffer.
14
15use std::time::Instant;
16
17use machi_agent::Agent;
18use machi_llm::LlmSampler;
19use machi_state::{ChatPersistence, ChatStateHandle};
20use machi_types::MachiError;
21
22use crate::metrics::{MetricsSink, NoopMetrics, record_turn};
23use crate::state::{ConversationState, VecConversationState};
24use crate::turn::{TurnInput, TurnOptions, TurnOutcome, TurnRuntime};
25
26/// Thin multi-turn orchestrator (one agent, persistent conversation state).
27#[derive(Debug, Clone, Copy)]
28pub struct Session {
29    runtime: TurnRuntime,
30    turn_count: u64,
31}
32
33impl Default for Session {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl Session {
40    /// Create a session.
41    #[must_use]
42    pub const fn new() -> Self {
43        Self {
44            runtime: TurnRuntime::new(),
45            turn_count: 0,
46        }
47    }
48
49    /// Number of turns completed successfully (or cancelled) through this session.
50    #[must_use]
51    pub const fn turn_count(self) -> u64 {
52        self.turn_count
53    }
54
55    /// Run one user turn, appending to `state`.
56    ///
57    /// # Errors
58    ///
59    /// Propagates [`TurnRuntime::run`] failures.
60    pub async fn run_turn(
61        &mut self,
62        agent: &Agent,
63        sampler: &dyn LlmSampler,
64        state: &mut dyn ConversationState,
65        input: TurnInput,
66        options: TurnOptions,
67    ) -> Result<TurnOutcome, MachiError> {
68        self.run_turn_with_metrics(agent, sampler, state, input, options, &NoopMetrics)
69            .await
70    }
71
72    /// Like [`Self::run_turn`] with metrics.
73    ///
74    /// # Errors
75    ///
76    /// Propagates turn failures.
77    pub async fn run_turn_with_metrics(
78        &mut self,
79        agent: &Agent,
80        sampler: &dyn LlmSampler,
81        state: &mut dyn ConversationState,
82        input: TurnInput,
83        options: TurnOptions,
84        metrics: &dyn MetricsSink,
85    ) -> Result<TurnOutcome, MachiError> {
86        let started = Instant::now();
87        let outcome = self
88            .runtime
89            .run(agent, sampler, state, input, options)
90            .await;
91        let ms = started.elapsed().as_secs_f64() * 1000.0;
92        match &outcome {
93            Ok(o) => {
94                self.turn_count = self.turn_count.saturating_add(1);
95                let status = if o.cancelled { "cancelled" } else { "ok" };
96                let steps = u64::try_from(o.steps).unwrap_or(u64::MAX);
97                record_turn(metrics, status, steps, ms);
98            }
99            Err(_) => {
100                record_turn(metrics, "error", 0, ms);
101            }
102        }
103        outcome
104    }
105
106    /// Run a turn against a [`ChatStateHandle`]: snapshot → local turn → replace + usage.
107    ///
108    /// The turn loop remains synchronous over a local buffer; the actor is the
109    /// durable source of truth between turns.
110    ///
111    /// # Errors
112    ///
113    /// Propagates turn or handle channel failures.
114    pub async fn run_turn_on_handle(
115        &mut self,
116        agent: &Agent,
117        sampler: &dyn LlmSampler,
118        handle: &ChatStateHandle,
119        input: TurnInput,
120        options: TurnOptions,
121    ) -> Result<TurnOutcome, MachiError> {
122        self.run_turn_on_handle_with_metrics(agent, sampler, handle, input, options, &NoopMetrics)
123            .await
124    }
125
126    /// [`Self::run_turn_on_handle`] with metrics.
127    ///
128    /// # Errors
129    ///
130    /// Propagates turn failures.
131    pub async fn run_turn_on_handle_with_metrics(
132        &mut self,
133        agent: &Agent,
134        sampler: &dyn LlmSampler,
135        handle: &ChatStateHandle,
136        input: TurnInput,
137        options: TurnOptions,
138        metrics: &dyn MetricsSink,
139    ) -> Result<TurnOutcome, MachiError> {
140        self.run_turn_on_handle_checkpointed(agent, sampler, handle, input, options, metrics, None)
141            .await
142    }
143
144    /// Session turn with optional durable checkpoint after each turn.
145    ///
146    /// When `store` is `Some`, the handle snapshot is written after the turn
147    /// (including failed turns that still mutated history).
148    ///
149    /// # Errors
150    ///
151    /// Turn failures or persistence I/O.
152    #[allow(
153        clippy::too_many_arguments,
154        reason = "session turn needs agent, sampler, handle, I/O, metrics, optional store"
155    )]
156    pub async fn run_turn_on_handle_checkpointed(
157        &mut self,
158        agent: &Agent,
159        sampler: &dyn LlmSampler,
160        handle: &ChatStateHandle,
161        input: TurnInput,
162        options: TurnOptions,
163        metrics: &dyn MetricsSink,
164        store: Option<&dyn ChatPersistence>,
165    ) -> Result<TurnOutcome, MachiError> {
166        let snap = handle.snapshot().await;
167        let mut local = VecConversationState::from_messages(snap.messages);
168        let outcome = self
169            .run_turn_with_metrics(agent, sampler, &mut local, input, options, metrics)
170            .await;
171        // Always fold local history back into the handle (session SoT).
172        handle.replace(local.messages().to_vec()).await;
173        if let Ok(o) = &outcome {
174            handle.record_main_usage(o.usage).await;
175            if o.cancelled {
176                handle.mark_incomplete().await;
177            }
178        } else {
179            handle.mark_incomplete().await;
180        }
181        if let Some(store) = store {
182            handle.save_to(store).await?;
183        }
184        outcome
185    }
186
187    /// Open a handle from `store` (or empty), run one checkpointed turn, return handle + outcome.
188    ///
189    /// Convenience for hosts that own a single file/session path.
190    ///
191    /// # Errors
192    ///
193    /// Load / turn / save failures.
194    #[allow(
195        clippy::too_many_arguments,
196        reason = "open+turn packs agent, sampler, store, input, options, metrics"
197    )]
198    pub async fn open_checkpointed_turn(
199        &mut self,
200        agent: &Agent,
201        sampler: &dyn LlmSampler,
202        store: &dyn ChatPersistence,
203        input: TurnInput,
204        options: TurnOptions,
205        metrics: &dyn MetricsSink,
206    ) -> Result<(ChatStateHandle, TurnOutcome), MachiError> {
207        let handle = ChatStateHandle::open_or_new(store).await?;
208        let outcome = self
209            .run_turn_on_handle_checkpointed(
210                agent,
211                sampler,
212                &handle,
213                input,
214                options,
215                metrics,
216                Some(store),
217            )
218            .await?;
219        Ok((handle, outcome))
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use std::sync::Arc;
226
227    use machi_agent::AgentBuilder;
228    use machi_llm::MockSampler;
229    use machi_state::ChatStateHandle;
230
231    use super::*;
232    use crate::state::VecConversationState;
233
234    #[tokio::test]
235    async fn multi_turn() {
236        let sampler = Arc::new(MockSampler::new());
237        sampler.push_text("one");
238        sampler.push_text("two");
239        let agent = AgentBuilder::named("a")
240            .model("mock")
241            .build()
242            .expect("agent");
243        let mut state = VecConversationState::new();
244        let mut session = Session::new();
245        let o1 = session
246            .run_turn(
247                &agent,
248                sampler.as_ref(),
249                &mut state,
250                TurnInput::Text("hi".into()),
251                TurnOptions::default(),
252            )
253            .await
254            .expect("t1");
255        let o2 = session
256            .run_turn(
257                &agent,
258                sampler.as_ref(),
259                &mut state,
260                TurnInput::Text("again".into()),
261                TurnOptions::default(),
262            )
263            .await
264            .expect("t2");
265        assert_eq!(o1.output_text, "one");
266        assert_eq!(o2.output_text, "two");
267        assert_eq!(session.turn_count(), 2);
268        assert!(state.messages().len() >= 4);
269    }
270
271    #[tokio::test]
272    async fn multi_turn_on_handle() {
273        let sampler = Arc::new(MockSampler::new());
274        sampler.push_text("alpha");
275        sampler.push_text("beta");
276        let agent = AgentBuilder::named("a")
277            .model("mock")
278            .build()
279            .expect("agent");
280        let handle = ChatStateHandle::spawn(vec![]);
281        let mut session = Session::new();
282        let o1 = session
283            .run_turn_on_handle(
284                &agent,
285                sampler.as_ref(),
286                &handle,
287                TurnInput::Text("hi".into()),
288                TurnOptions::default(),
289            )
290            .await
291            .expect("t1");
292        let o2 = session
293            .run_turn_on_handle(
294                &agent,
295                sampler.as_ref(),
296                &handle,
297                TurnInput::Text("again".into()),
298                TurnOptions::default(),
299            )
300            .await
301            .expect("t2");
302        assert_eq!(o1.output_text, "alpha");
303        assert_eq!(o2.output_text, "beta");
304        let snap = handle.snapshot().await;
305        assert!(snap.messages.len() >= 4);
306        assert!(snap.usage.main.total_tokens > 0);
307        handle.shutdown().await;
308    }
309
310    #[tokio::test]
311    async fn handle_checkpoint_to_memory() {
312        use machi_state::MemoryPersistence;
313
314        let sampler = Arc::new(MockSampler::new());
315        sampler.push_text("ckpt");
316        let agent = AgentBuilder::named("a")
317            .model("mock")
318            .build()
319            .expect("agent");
320        let handle = ChatStateHandle::spawn(vec![]);
321        let store = MemoryPersistence::default();
322        let mut session = Session::new();
323        session
324            .run_turn_on_handle_checkpointed(
325                &agent,
326                sampler.as_ref(),
327                &handle,
328                TurnInput::Text("hi".into()),
329                TurnOptions::default(),
330                &NoopMetrics,
331                Some(&store),
332            )
333            .await
334            .expect("turn");
335        let loaded = store.load().await.expect("load").expect("some");
336        assert!(!loaded.messages.is_empty());
337        handle.shutdown().await;
338    }
339
340    #[tokio::test]
341    async fn open_checkpointed_turn_round_trip() {
342        use machi_state::MemoryPersistence;
343
344        let sampler = Arc::new(MockSampler::new());
345        sampler.push_text("one");
346        sampler.push_text("two");
347        let agent = AgentBuilder::named("a")
348            .model("mock")
349            .build()
350            .expect("agent");
351        let store = MemoryPersistence::default();
352        let mut session = Session::new();
353        let (h1, o1) = session
354            .open_checkpointed_turn(
355                &agent,
356                sampler.as_ref(),
357                &store,
358                TurnInput::Text("a".into()),
359                TurnOptions::default(),
360                &NoopMetrics,
361            )
362            .await
363            .expect("t1");
364        assert_eq!(o1.output_text, "one");
365        h1.shutdown().await;
366
367        let (h2, o2) = session
368            .open_checkpointed_turn(
369                &agent,
370                sampler.as_ref(),
371                &store,
372                TurnInput::Text("b".into()),
373                TurnOptions::default(),
374                &NoopMetrics,
375            )
376            .await
377            .expect("t2");
378        assert_eq!(o2.output_text, "two");
379        // History includes both turns after reload.
380        assert!(h2.messages().await.len() >= 4);
381        h2.shutdown().await;
382    }
383}