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