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, TerminalRequirementFailure};
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    /// A terminal candidate failed its completion contract and cannot be
45    /// repaired under the configured policy.
46    TerminalRequirementFailed {
47        /// Safe failure details retained without the generated body.
48        failure: TerminalRequirementFailure,
49        /// Repair turns completed before exhaustion.
50        attempts: u32,
51    },
52}
53
54/// Conversation commit preconditions carried through crash recovery.
55#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
56pub struct DurableConversationCheckpoint {
57    /// Conversation receiving the completed turn.
58    pub conversation_id: ConversationId,
59    /// Isolation namespace loaded before execution.
60    pub namespace: MemoryNamespace,
61    /// Transcript version loaded before execution.
62    pub expected_version: ConversationVersion,
63    /// Number of leading runtime-only messages excluded from persistence.
64    pub persisted_prefix_len: u64,
65}
66
67/// Versioned Agent state stored in a domain-neutral checkpoint envelope.
68#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
69pub struct AgentCheckpointState {
70    /// Stable logical execution identity used for callable idempotency.
71    pub execution_id: String,
72    /// Agent identity expected during recovery.
73    pub agent: String,
74    /// Model identity expected during recovery.
75    pub model: ModelRef,
76    /// Canonical transcript at the last stable boundary.
77    pub transcript: Vec<Message>,
78    /// Completed model turns.
79    pub turns: u32,
80    /// Completed local tool attempts.
81    pub tool_calls: u32,
82    /// Completed successful delegations.
83    pub delegations: u32,
84    /// Shared usage snapshot at persistence time.
85    pub usage: Usage,
86    /// Current recovery phase.
87    pub phase: AgentCheckpointPhase,
88    /// Atomic conversation commit metadata, when this is a durable turn.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub durable_conversation: Option<DurableConversationCheckpoint>,
91}
92
93impl AgentCheckpointState {
94    pub(crate) fn outcome(&self) -> Option<AgentOutcome> {
95        match &self.phase {
96            AgentCheckpointPhase::Completed { response } => Some(AgentOutcome {
97                response: response.as_ref().clone(),
98                transcript: self.transcript.clone(),
99                turns: self.turns,
100                tool_calls: self.tool_calls,
101                delegations: self.delegations,
102                usage: self.usage,
103            }),
104            _ => None,
105        }
106    }
107
108    pub(crate) fn terminal_failure(&self) -> Option<AgentError> {
109        match &self.phase {
110            AgentCheckpointPhase::TerminalRequirementFailed {
111                failure, attempts, ..
112            } => Some(super::agent::completion::failure_error(failure, *attempts)),
113            _ => None,
114        }
115    }
116}
117
118/// Stable handle binding one checkpoint identity to a store.
119#[derive(Clone)]
120pub struct AgentCheckpoint {
121    id: CheckpointId,
122    store: Arc<dyn CheckpointStore>,
123}
124
125impl AgentCheckpoint {
126    /// Creates a new checkpoint handle with a unique identity.
127    pub fn new(store: Arc<dyn CheckpointStore>) -> Self {
128        Self {
129            id: CheckpointId::new(),
130            store,
131        }
132    }
133
134    /// Reconnects to an existing checkpoint identity.
135    pub fn existing(id: CheckpointId, store: Arc<dyn CheckpointStore>) -> Self {
136        Self { id, store }
137    }
138
139    /// Returns the stable checkpoint identity.
140    pub const fn id(&self) -> CheckpointId {
141        self.id
142    }
143
144    /// Loads and validates the latest typed Agent state.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`CheckpointError`] when storage or payload validation fails.
149    pub fn load(&self) -> Result<(Checkpoint, AgentCheckpointState), CheckpointError> {
150        let checkpoint = self.store.load(self.id)?;
151        if checkpoint.kind != CHECKPOINT_KIND
152            || checkpoint.schema_version != CHECKPOINT_SCHEMA_VERSION
153        {
154            return Err(CheckpointError::new(
155                CheckpointErrorKind::InvalidPayload,
156                "checkpoint kind or schema version is not supported",
157            ));
158        }
159        let state = serde_json::from_value(checkpoint.payload.clone()).map_err(|error| {
160            CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
161        })?;
162        Ok((checkpoint, state))
163    }
164}
165
166impl fmt::Debug for AgentCheckpoint {
167    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
168        formatter
169            .debug_struct("AgentCheckpoint")
170            .field("id", &self.id)
171            .finish_non_exhaustive()
172    }
173}
174
175pub(crate) struct CheckpointCursor {
176    handle: AgentCheckpoint,
177    envelope: Checkpoint,
178}
179
180impl CheckpointCursor {
181    pub(crate) fn create(
182        handle: &AgentCheckpoint,
183        run: &RunContext,
184        state: &AgentCheckpointState,
185    ) -> Result<Self, AgentError> {
186        let payload = serialize(state)?;
187        let envelope = Checkpoint::initial(
188            handle.id,
189            run.run_id(),
190            CHECKPOINT_KIND,
191            CHECKPOINT_SCHEMA_VERSION,
192            payload,
193        );
194        handle.store.compare_and_swap(&envelope, None)?;
195        Ok(Self {
196            handle: handle.clone(),
197            envelope,
198        })
199    }
200
201    pub(crate) fn loaded(handle: &AgentCheckpoint, envelope: Checkpoint) -> Self {
202        Self {
203            handle: handle.clone(),
204            envelope,
205        }
206    }
207
208    pub(crate) fn save(&mut self, state: &AgentCheckpointState) -> Result<(), AgentError> {
209        let next = self.envelope.next(serialize(state)?)?;
210        self.handle
211            .store
212            .compare_and_swap(&next, Some(self.envelope.revision))?;
213        self.envelope = next;
214        Ok(())
215    }
216
217    pub(crate) fn next(&self, state: &AgentCheckpointState) -> Result<Checkpoint, AgentError> {
218        self.envelope.next(serialize(state)?).map_err(Into::into)
219    }
220
221    pub(crate) const fn revision(&self) -> u64 {
222        self.envelope.revision
223    }
224
225    pub(crate) const fn id(&self) -> CheckpointId {
226        self.envelope.id
227    }
228}
229
230fn serialize(state: &AgentCheckpointState) -> Result<serde_json::Value, AgentError> {
231    serde_json::to_value(state).map_err(|error| {
232        CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string()).into()
233    })
234}