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