Skip to main content

runifold_agent/conversation/
session.rs

1//! Durable per-conversation admission and request identity.
2
3use std::sync::Arc;
4
5use runifold_core::{Checkpoint, CheckpointError, CheckpointErrorKind, CheckpointId, RunContext};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use thiserror::Error;
9
10use crate::stream::{AgentObserver, BufferedObserver, NoopObserver};
11use crate::{
12    Agent, AgentCheckpoint, AgentCheckpointPhase, AgentConversationError, AgentConversationOutcome,
13    AgentEventStream, AgentFuture, AgentStreamEvent, ConversationContextPolicy, ConversationId,
14    DurableConversationRequest, DurableConversationStore, MemoryNamespace, ResumePolicy,
15};
16
17mod summary;
18use summary::{SummaryConfig, SummaryProgress};
19
20const KIND: &str = "runifold.conversation.admission";
21
22/// A durable conversation with exclusive admission before any model or tool work.
23///
24/// All writers must use this boundary. Low-level conversation APIs intentionally
25/// remain available and do not honor this admission record. The conversation's
26/// UUID is reserved as its admission checkpoint ID; request IDs must differ.
27#[derive(Clone)]
28#[doc(alias = "durable conversation")]
29#[doc(alias = "request replay")]
30pub struct AgentSession {
31    agent: Agent,
32    store: Arc<dyn DurableConversationStore>,
33    conversation_id: ConversationId,
34    namespace: MemoryNamespace,
35    policy: ConversationContextPolicy,
36    summary: Option<SummaryConfig>,
37}
38
39/// Admission and execution errors from a durable session.
40#[derive(Debug, Error)]
41pub enum AgentSessionError {
42    /// Another request owns the conversation, possibly after a crash.
43    #[error("conversation is occupied by request {request_id} at revision {revision}")]
44    Busy {
45        /// Request requiring completion or explicit recovery.
46        request_id: CheckpointId,
47        /// Exact revision required for an explicit recovery attempt.
48        revision: u64,
49    },
50    /// The request identity is already bound to another input or conversation.
51    #[error("request identity does not match its original input or conversation")]
52    RequestMismatch,
53    /// Persisted admission data cannot be interpreted safely.
54    #[error("invalid session admission record")]
55    InvalidAdmission,
56    /// Session context or summary configuration differs from the admitted request.
57    #[error("session configuration does not match the admitted request")]
58    ConfigurationMismatch,
59    /// Restored budget is inconsistent with the latest stable session snapshot.
60    #[error("run usage does not match the session checkpoint")]
61    UsageMismatch,
62    /// The underlying checkpoint store rejected the operation.
63    #[error(transparent)]
64    Checkpoint(#[from] CheckpointError),
65    /// The admitted execution failed. Admission remains held for recovery.
66    #[error(transparent)]
67    Conversation(Box<AgentConversationError>),
68}
69
70impl From<AgentConversationError> for AgentSessionError {
71    fn from(error: AgentConversationError) -> Self {
72        Self::Conversation(Box::new(error))
73    }
74}
75
76#[derive(Deserialize, Serialize)]
77struct Admission {
78    namespace: MemoryNamespace,
79    active: Option<AdmittedRequest>,
80}
81
82#[derive(Deserialize, Serialize)]
83struct AdmittedRequest {
84    id: CheckpointId,
85    input_digest: [u8; 32],
86    agent: summary::AgentDefinition,
87    context: (u16, u16, Option<u16>),
88    summary: Option<SummaryProgress>,
89    usage: runifold_core::Usage,
90}
91
92impl AgentSession {
93    /// Binds an Agent and durable store to one conversation and context policy.
94    pub fn new(
95        agent: Agent,
96        store: Arc<dyn DurableConversationStore>,
97        conversation_id: ConversationId,
98        namespace: MemoryNamespace,
99        policy: ConversationContextPolicy,
100    ) -> Self {
101        Self {
102            agent,
103            store,
104            conversation_id,
105            namespace,
106            policy,
107            summary: None,
108        }
109    }
110
111    /// Compacts older transcript batches with a checkpointed Agent before a turn.
112    /// Summary work shares the caller's capabilities, budget, and deadline.
113    /// The pass limit applies across retries of the same request.
114    #[must_use]
115    pub fn with_summary_agent(
116        mut self,
117        agent: Agent,
118        max_passes: crate::ConversationSummaryPassLimit,
119    ) -> Self {
120        self.summary = Some(SummaryConfig { agent, max_passes });
121        self
122    }
123
124    /// Returns the latest persisted usage for an occupied request.
125    /// In-flight remote work can consume more than this snapshot; reconcile that
126    /// uncertainty before explicitly authorizing an interrupted-turn retry.
127    ///
128    /// # Errors
129    /// Returns an error for an unoccupied or mismatched request, invalid admission
130    /// data, or an unreadable checkpoint.
131    pub fn recovery_usage(
132        &self,
133        request_id: CheckpointId,
134    ) -> Result<runifold_core::Usage, AgentSessionError> {
135        let admission =
136            self.read_admission(&self.store.load(self.conversation_id.as_checkpoint_id())?)?;
137        let active = admission
138            .active
139            .ok_or(AgentSessionError::InvalidAdmission)?;
140        if active.id != request_id {
141            return Err(AgentSessionError::RequestMismatch);
142        }
143        for id in std::iter::once(request_id).chain(
144            active
145                .summary
146                .as_ref()
147                .and_then(|state| state.pending.as_ref())
148                .map(|pass| pass.checkpoint_id),
149        ) {
150            let store: Arc<dyn runifold_core::CheckpointStore> = self.store.clone();
151            match AgentCheckpoint::existing(id, store).load() {
152                Ok((_, state)) => return Ok(summary::usage_floor(active.usage, state.usage)),
153                Err(error) if error.kind == CheckpointErrorKind::NotFound => {}
154                Err(error) => return Err(error.into()),
155            }
156        }
157        Ok(active.usage)
158    }
159
160    /// Runs one request. Reuse the same ID and exact input for transport retries.
161    /// Completed requests replay their stored result; active ones return `Busy`.
162    /// Failure or cancellation retains admission until explicit recovery.
163    pub fn run<'a>(
164        &'a self,
165        request_id: CheckpointId,
166        input: impl Into<String> + Send + 'a,
167        run: &'a RunContext,
168    ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentSessionError>> {
169        self.execute(request_id, input.into(), run, Arc::new(NoopObserver), None)
170    }
171
172    /// Streams an admitted request. Dropping it retains admission for recovery.
173    /// `ConversationCommitted` is emitted after commit and admission release.
174    pub fn stream<'a>(
175        &'a self,
176        request_id: CheckpointId,
177        input: impl Into<String> + Send + 'a,
178        run: &'a RunContext,
179    ) -> AgentEventStream<'a, AgentSessionError> {
180        let observer = Arc::new(BufferedObserver::durable());
181        let events = observer.events();
182        let execution = self.execute(request_id, input.into(), run, observer.clone(), None);
183        AgentEventStream::new(
184            Box::pin(async move {
185                let result = execution.await?;
186                observer.emit(AgentStreamEvent::ConversationCommitted {
187                    outcome: result.outcome.clone(),
188                    conversation_version: result.conversation_version,
189                });
190                Ok(result.outcome)
191            }),
192            events,
193        )
194    }
195
196    /// Recovers an occupied request after the host has stopped its prior owner.
197    ///
198    /// The host MUST ensure the previous execution can no longer run. Revision
199    /// CAS prevents competing recovery claims; it cannot fence external services.
200    /// Restore the run's budget using [`Self::recovery_usage`] before calling this.
201    /// A request interrupted before checkpoint creation starts from its bound input.
202    pub fn recover_after_owner_exit<'a>(
203        &'a self,
204        request_id: CheckpointId,
205        input: impl Into<String> + Send + 'a,
206        run: &'a RunContext,
207        expected_revision: u64,
208        policy: ResumePolicy,
209    ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentSessionError>> {
210        self.execute(
211            request_id,
212            input.into(),
213            run,
214            Arc::new(NoopObserver),
215            Some((expected_revision, policy)),
216        )
217    }
218
219    fn execute<'a>(
220        &'a self,
221        request_id: CheckpointId,
222        input: String,
223        run: &'a RunContext,
224        observer: Arc<dyn AgentObserver>,
225        recovery: Option<(u64, ResumePolicy)>,
226    ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentSessionError>> {
227        Box::pin(async move {
228            if request_id == self.conversation_id.as_checkpoint_id() {
229                return Err(AgentSessionError::RequestMismatch);
230            }
231            let existing = self.existing_request(request_id, &input)?;
232            if existing == Some(true) && recovery.is_none() {
233                return Ok(self
234                    .agent
235                    .resume_durable_conversation(
236                        self.store.clone(),
237                        request_id,
238                        run,
239                        ResumePolicy::RejectAmbiguous,
240                    )
241                    .await?);
242            }
243            let mut admission = self.claim(
244                request_id,
245                &input,
246                run,
247                recovery.map(|(revision, _)| revision),
248            )?;
249            // Re-read after admission: another process may have committed between
250            // our initial lookup and claiming the idle record.
251            let existing = self.existing_request(request_id, &input)?;
252            let result = if existing.is_some() {
253                self.agent
254                    .resume_durable_conversation_observed(
255                        self.store.clone(),
256                        request_id,
257                        run,
258                        recovery.map_or(ResumePolicy::RejectAmbiguous, |(_, policy)| policy),
259                        observer,
260                    )
261                    .await?
262            } else {
263                self.compact_summary(
264                    &mut admission,
265                    run,
266                    observer.as_ref(),
267                    recovery.map_or(ResumePolicy::RejectAmbiguous, |(_, policy)| policy),
268                )
269                .await?;
270                self.agent
271                    .run_durable_conversation_observed(
272                        input,
273                        run,
274                        self.store.clone(),
275                        DurableConversationRequest {
276                            checkpoint_id: request_id,
277                            conversation_id: self.conversation_id,
278                            namespace: self.namespace.clone(),
279                            policy: self.policy,
280                        },
281                        observer,
282                    )
283                    .await?
284            };
285            let idle = admission.next(self.payload(None)?)?;
286            self.store
287                .compare_and_swap(&idle, Some(admission.revision))?;
288            Ok(result)
289        })
290    }
291
292    fn existing_request(
293        &self,
294        id: CheckpointId,
295        input: &str,
296    ) -> Result<Option<bool>, AgentSessionError> {
297        let store: Arc<dyn runifold_core::CheckpointStore> = self.store.clone();
298        let checkpoint = AgentCheckpoint::existing(id, store);
299        let (_, state) = match checkpoint.load() {
300            Ok(value) => value,
301            Err(error) if error.kind == CheckpointErrorKind::NotFound => return Ok(None),
302            Err(error) => return Err(error.into()),
303        };
304        let durable = state
305            .durable_conversation
306            .as_ref()
307            .ok_or(AgentSessionError::RequestMismatch)?;
308        let index = usize::try_from(durable.persisted_prefix_len)
309            .map_err(|_| AgentSessionError::RequestMismatch)?;
310        // Context retrieval can insert transient messages before the original user
311        // message. Locate the first non-transient message in the appended suffix.
312        let message = state
313            .transcript
314            .iter()
315            .skip(index)
316            .find(|message| !super::is_transient_context(message));
317        if durable.conversation_id != self.conversation_id
318            || durable.namespace != self.namespace
319            || message != Some(&runifold_model::Message::user(input))
320        {
321            return Err(AgentSessionError::RequestMismatch);
322        }
323        Ok(Some(matches!(
324            state.phase,
325            AgentCheckpointPhase::Completed { .. }
326        )))
327    }
328
329    fn claim(
330        &self,
331        id: CheckpointId,
332        input: &str,
333        run: &RunContext,
334        recovery: Option<u64>,
335    ) -> Result<Checkpoint, AgentSessionError> {
336        let gate_id = self.conversation_id.as_checkpoint_id();
337        let digest: [u8; 32] = Sha256::digest(input.as_bytes()).into();
338        let payload = self.payload(Some(AdmittedRequest {
339            id,
340            input_digest: digest,
341            agent: summary::AgentDefinition::new(&self.agent),
342            context: self.context_contract(),
343            summary: self.summary.as_ref().map(SummaryProgress::new),
344            usage: run.budget().usage(),
345        }))?;
346        let current = match self.store.load(gate_id) {
347            Ok(current) => current,
348            Err(error) if error.kind == CheckpointErrorKind::NotFound && recovery.is_none() => {
349                let first = Checkpoint::initial(gate_id, run.run_id(), KIND, 3, payload);
350                self.store.compare_and_swap(&first, None)?;
351                return Ok(first);
352            }
353            Err(error) => return Err(error.into()),
354        };
355        let state = self.read_admission(&current)?;
356        let mut payload = payload;
357        if let Some(active) = state.active {
358            if active.id == id && active.input_digest != digest {
359                return Err(AgentSessionError::RequestMismatch);
360            }
361            if recovery != Some(current.revision) || active.id != id {
362                return Err(AgentSessionError::Busy {
363                    request_id: active.id,
364                    revision: current.revision,
365                });
366            }
367            if active.agent != summary::AgentDefinition::new(&self.agent)
368                || active.context != self.context_contract()
369                || active.summary.as_ref().map(|state| &state.contract)
370                    != self.summary.as_ref().map(SummaryConfig::contract).as_ref()
371            {
372                return Err(AgentSessionError::ConfigurationMismatch);
373            }
374            // A recovery claim preserves summary checkpoint identity and usage.
375            payload = self.payload(Some(active))?;
376        } else if recovery.is_some() {
377            return Err(AgentSessionError::InvalidAdmission);
378        }
379        let next = current.next(payload)?;
380        self.store.compare_and_swap(&next, Some(current.revision))?;
381        Ok(next)
382    }
383
384    fn context_contract(&self) -> (u16, u16, Option<u16>) {
385        (
386            self.policy.window.get(),
387            self.policy.summary_batch.get(),
388            self.policy
389                .semantic_memory_limit
390                .map(std::num::NonZeroU16::get),
391        )
392    }
393
394    fn read_admission(&self, checkpoint: &Checkpoint) -> Result<Admission, AgentSessionError> {
395        if checkpoint.kind != KIND || checkpoint.schema_version != 3 {
396            return Err(AgentSessionError::InvalidAdmission);
397        }
398        let state: Admission = serde_json::from_value(checkpoint.payload.clone())
399            .map_err(|_| AgentSessionError::InvalidAdmission)?;
400        if state.namespace != self.namespace {
401            return Err(AgentSessionError::RequestMismatch);
402        }
403        Ok(state)
404    }
405
406    fn save_admission(
407        &self,
408        checkpoint: &mut Checkpoint,
409        active: AdmittedRequest,
410    ) -> Result<(), AgentSessionError> {
411        let next = checkpoint.next(self.payload(Some(active))?)?;
412        self.store
413            .compare_and_swap(&next, Some(checkpoint.revision))?;
414        *checkpoint = next;
415        Ok(())
416    }
417
418    fn payload(
419        &self,
420        active: Option<AdmittedRequest>,
421    ) -> Result<serde_json::Value, AgentSessionError> {
422        serde_json::to_value(Admission {
423            namespace: self.namespace.clone(),
424            active,
425        })
426        .map_err(|_| AgentSessionError::InvalidAdmission)
427    }
428}
429
430impl std::fmt::Debug for AgentSession {
431    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432        formatter
433            .debug_struct("AgentSession")
434            .field("conversation_id", &self.conversation_id)
435            .field("namespace", &self.namespace)
436            .finish_non_exhaustive()
437    }
438}