Skip to main content

runifold_agent/
checkpoint.rs

1use std::{fmt, sync::Arc};
2
3use runifold_core::{
4    Checkpoint, CheckpointError, CheckpointErrorKind, CheckpointId, CheckpointStore, RunContext,
5    Usage,
6};
7use runifold_model::{Message, ModelRef, ModelResponse};
8use serde::{Deserialize, Serialize};
9
10use crate::conversation::{ConversationId, ConversationVersion, MemoryNamespace};
11use crate::{AgentError, AgentOutcome};
12
13const CHECKPOINT_KIND: &str = "runifold.agent";
14const CHECKPOINT_SCHEMA_VERSION: u32 = 1;
15
16/// Recovery behavior for a checkpoint captured during an external turn.
17#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
18#[non_exhaustive]
19pub enum ResumePolicy {
20    /// Reject recovery that could duplicate model cost or external effects.
21    #[default]
22    RejectAmbiguous,
23    /// Explicitly retry the entire interrupted model-and-callable turn.
24    RetryInterruptedTurn,
25}
26
27/// Persisted Agent execution phase.
28#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
29#[serde(tag = "state", rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum AgentCheckpointPhase {
32    /// Transcript is stable and ready for the next model turn.
33    ReadyForTurn,
34    /// A model-and-callable turn may have partially executed.
35    TurnInFlight {
36        /// One-based turn number that may have partially executed.
37        turn: u32,
38    },
39    /// The Agent reached a terminal response.
40    Completed {
41        /// Final canonical model response.
42        response: Box<ModelResponse>,
43    },
44}
45
46/// Conversation commit preconditions carried through crash recovery.
47#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48pub struct DurableConversationCheckpoint {
49    /// Conversation receiving the completed turn.
50    pub conversation_id: ConversationId,
51    /// Isolation namespace loaded before execution.
52    pub namespace: MemoryNamespace,
53    /// Transcript version loaded before execution.
54    pub expected_version: ConversationVersion,
55    /// Number of leading runtime-only messages excluded from persistence.
56    pub persisted_prefix_len: u64,
57}
58
59/// Versioned Agent state stored in a domain-neutral checkpoint envelope.
60#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
61pub struct AgentCheckpointState {
62    /// Stable logical execution identity used for callable idempotency.
63    pub execution_id: String,
64    /// Agent identity expected during recovery.
65    pub agent: String,
66    /// Model identity expected during recovery.
67    pub model: ModelRef,
68    /// Canonical transcript at the last stable boundary.
69    pub transcript: Vec<Message>,
70    /// Completed model turns.
71    pub turns: u32,
72    /// Completed local tool attempts.
73    pub tool_calls: u32,
74    /// Completed successful delegations.
75    pub delegations: u32,
76    /// Shared usage snapshot at persistence time.
77    pub usage: Usage,
78    /// Current recovery phase.
79    pub phase: AgentCheckpointPhase,
80    /// Atomic conversation commit metadata, when this is a durable turn.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub durable_conversation: Option<DurableConversationCheckpoint>,
83}
84
85impl AgentCheckpointState {
86    pub(crate) fn outcome(&self) -> Option<AgentOutcome> {
87        match &self.phase {
88            AgentCheckpointPhase::Completed { response } => Some(AgentOutcome {
89                response: response.as_ref().clone(),
90                transcript: self.transcript.clone(),
91                turns: self.turns,
92                tool_calls: self.tool_calls,
93                delegations: self.delegations,
94                usage: self.usage,
95            }),
96            _ => None,
97        }
98    }
99}
100
101/// Stable handle binding one checkpoint identity to a store.
102#[derive(Clone)]
103pub struct AgentCheckpoint {
104    id: CheckpointId,
105    store: Arc<dyn CheckpointStore>,
106}
107
108impl AgentCheckpoint {
109    /// Creates a new checkpoint handle with a unique identity.
110    pub fn new(store: Arc<dyn CheckpointStore>) -> Self {
111        Self {
112            id: CheckpointId::new(),
113            store,
114        }
115    }
116
117    /// Reconnects to an existing checkpoint identity.
118    pub fn existing(id: CheckpointId, store: Arc<dyn CheckpointStore>) -> Self {
119        Self { id, store }
120    }
121
122    /// Returns the stable checkpoint identity.
123    pub const fn id(&self) -> CheckpointId {
124        self.id
125    }
126
127    /// Loads and validates the latest typed Agent state.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`CheckpointError`] when storage or payload validation fails.
132    pub fn load(&self) -> Result<(Checkpoint, AgentCheckpointState), CheckpointError> {
133        let checkpoint = self.store.load(self.id)?;
134        if checkpoint.kind != CHECKPOINT_KIND
135            || checkpoint.schema_version != CHECKPOINT_SCHEMA_VERSION
136        {
137            return Err(CheckpointError::new(
138                CheckpointErrorKind::InvalidPayload,
139                "checkpoint kind or schema version is not supported",
140            ));
141        }
142        let state = serde_json::from_value(checkpoint.payload.clone()).map_err(|error| {
143            CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
144        })?;
145        Ok((checkpoint, state))
146    }
147}
148
149impl fmt::Debug for AgentCheckpoint {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        formatter
152            .debug_struct("AgentCheckpoint")
153            .field("id", &self.id)
154            .finish_non_exhaustive()
155    }
156}
157
158pub(crate) struct CheckpointCursor {
159    handle: AgentCheckpoint,
160    envelope: Checkpoint,
161}
162
163impl CheckpointCursor {
164    pub(crate) fn create(
165        handle: &AgentCheckpoint,
166        run: &RunContext,
167        state: &AgentCheckpointState,
168    ) -> Result<Self, AgentError> {
169        let payload = serialize(state)?;
170        let envelope = Checkpoint::initial(
171            handle.id,
172            run.run_id(),
173            CHECKPOINT_KIND,
174            CHECKPOINT_SCHEMA_VERSION,
175            payload,
176        );
177        handle.store.compare_and_swap(&envelope, None)?;
178        Ok(Self {
179            handle: handle.clone(),
180            envelope,
181        })
182    }
183
184    pub(crate) fn loaded(handle: &AgentCheckpoint, envelope: Checkpoint) -> Self {
185        Self {
186            handle: handle.clone(),
187            envelope,
188        }
189    }
190
191    pub(crate) fn save(&mut self, state: &AgentCheckpointState) -> Result<(), AgentError> {
192        let next = self.envelope.next(serialize(state)?)?;
193        self.handle
194            .store
195            .compare_and_swap(&next, Some(self.envelope.revision))?;
196        self.envelope = next;
197        Ok(())
198    }
199
200    pub(crate) fn next(&self, state: &AgentCheckpointState) -> Result<Checkpoint, AgentError> {
201        self.envelope.next(serialize(state)?).map_err(Into::into)
202    }
203
204    pub(crate) const fn revision(&self) -> u64 {
205        self.envelope.revision
206    }
207
208    pub(crate) const fn id(&self) -> CheckpointId {
209        self.envelope.id
210    }
211}
212
213fn serialize(state: &AgentCheckpointState) -> Result<serde_json::Value, AgentError> {
214    serde_json::to_value(state).map_err(|error| {
215        CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string()).into()
216    })
217}