Skip to main content

runifold_agent/
checkpoint.rs

1use std::{fmt, sync::Arc};
2
3use runifold_core::{
4    CapabilityId, Checkpoint, CheckpointError, CheckpointErrorKind, CheckpointId, CheckpointStore,
5    RunContext, Usage,
6};
7use runifold_model::{Message, ModelRef, ModelResponse};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::conversation::{ConversationId, ConversationVersion, MemoryNamespace};
12use crate::{
13    AgentError, AgentOutcome, TerminalRequirementFailure, TerminalReviewPolicy,
14    TerminalReviewerDescriptor, TurnReviewPolicy,
15};
16
17const CHECKPOINT_KIND: &str = "runifold.agent";
18const CHECKPOINT_SCHEMA_VERSION: u32 = 1;
19
20/// Recovery behavior for a checkpoint captured during an external operation.
21#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
22#[non_exhaustive]
23pub enum ResumePolicy {
24    /// Reject recovery that could duplicate model cost or external effects.
25    #[default]
26    RejectAmbiguous,
27    /// Explicitly retry an interrupted model-and-callable turn, internal turn
28    /// review, or terminal review. Durable review candidates and approved turn
29    /// plans are reused without regenerating them.
30    RetryInterruptedTurn,
31}
32
33/// Persisted Agent execution phase.
34#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
35#[serde(tag = "state", rename_all = "snake_case")]
36#[non_exhaustive]
37pub enum AgentCheckpointPhase {
38    /// Transcript is stable and ready for the next model turn.
39    ReadyForTurn,
40    /// A model-and-callable turn may have partially executed.
41    TurnInFlight {
42        /// One-based turn number that may have partially executed.
43        turn: u32,
44    },
45    /// The Agent reached a terminal response.
46    Completed {
47        /// Final canonical model response.
48        response: Box<ModelResponse>,
49    },
50    /// A terminal candidate failed its completion contract and cannot be
51    /// repaired under the configured policy.
52    TerminalRequirementFailed {
53        /// Safe failure details retained without the generated body.
54        failure: TerminalRequirementFailure,
55        /// Repair turns completed before exhaustion.
56        attempts: u32,
57    },
58    /// A selected model response is durable and has not entered internal
59    /// turn review.
60    TurnReviewReady {
61        /// Response awaiting review before it can affect execution.
62        response: Box<ModelResponse>,
63        /// One-based model turn that produced the response.
64        turn: u32,
65    },
66    /// Internal review of a durable model response may have partially
67    /// executed.
68    TurnReviewInFlight {
69        /// Response supplied to the reviewer.
70        response: Box<ModelResponse>,
71        /// One-based model turn that produced the response.
72        turn: u32,
73    },
74    /// The response passed review and is durable while its tool plan is
75    /// applied or its terminal path is completed.
76    TurnReviewApproved {
77        /// Approved response reused during recovery without regeneration.
78        response: Box<ModelResponse>,
79        /// One-based model turn that produced the response.
80        turn: u32,
81    },
82    /// An internal reviewer permanently rejected a model response.
83    TurnReviewRejected {
84        /// Response rejected by the reviewer.
85        response: Box<ModelResponse>,
86        /// One-based model turn that produced the response.
87        turn: u32,
88        /// Safe reviewer explanation.
89        reason: String,
90        /// Internal review repairs completed before rejection.
91        attempts: u32,
92    },
93    /// A turn repair verdict exceeded the configured review repair limit.
94    TurnReviewExhausted {
95        /// Last response that required another repair.
96        response: Box<ModelResponse>,
97        /// One-based model turn that produced the response.
98        turn: u32,
99        /// Last bounded feedback returned by the reviewer.
100        feedback: Value,
101        /// Internal review repairs completed before exhaustion.
102        attempts: u32,
103    },
104    /// A locally valid candidate is durable and has not entered review.
105    TerminalReviewReady {
106        /// Candidate awaiting semantic review.
107        response: Box<ModelResponse>,
108        /// One-based semantic review attempt.
109        attempt: u32,
110    },
111    /// Semantic review of a durable candidate may have partially executed.
112    TerminalReviewInFlight {
113        /// Candidate supplied to the reviewer.
114        response: Box<ModelResponse>,
115        /// One-based semantic review attempt.
116        attempt: u32,
117    },
118    /// A reviewer permanently rejected a terminal candidate.
119    TerminalReviewRejected {
120        /// Candidate rejected by the reviewer.
121        response: Box<ModelResponse>,
122        /// Safe reviewer explanation.
123        reason: String,
124        /// Review repairs completed before rejection.
125        attempts: u32,
126    },
127    /// A repair verdict exceeded the configured review repair limit.
128    TerminalReviewExhausted {
129        /// Last candidate that required another repair.
130        response: Box<ModelResponse>,
131        /// Last bounded feedback returned by the reviewer.
132        feedback: Value,
133        /// Review repairs completed before exhaustion.
134        attempts: u32,
135    },
136}
137
138/// Conversation commit preconditions carried through crash recovery.
139#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
140pub struct DurableConversationCheckpoint {
141    /// Conversation receiving the completed turn.
142    pub conversation_id: ConversationId,
143    /// Isolation namespace loaded before execution.
144    pub namespace: MemoryNamespace,
145    /// Transcript version loaded before execution.
146    pub expected_version: ConversationVersion,
147    /// Number of leading runtime-only messages excluded from persistence.
148    pub persisted_prefix_len: u64,
149}
150
151/// Versioned Agent state stored in a domain-neutral checkpoint envelope.
152#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
153pub struct AgentCheckpointState {
154    /// Stable logical execution identity used for callable idempotency.
155    pub execution_id: String,
156    /// Agent identity expected during recovery.
157    pub agent: String,
158    /// Model identity expected during recovery.
159    pub model: ModelRef,
160    /// Canonical transcript at the last stable boundary.
161    pub transcript: Vec<Message>,
162    /// Completed model turns.
163    pub turns: u32,
164    /// Completed local tool attempts.
165    pub tool_calls: u32,
166    /// Completed successful delegations.
167    pub delegations: u32,
168    /// Shared usage snapshot at persistence time.
169    pub usage: Usage,
170    /// Stable internal-turn reviewer identity expected throughout recovery.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub turn_reviewer: Option<TerminalReviewerDescriptor>,
173    /// Internal-turn review scope and repair limit expected during recovery.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub turn_review_policy: Option<TurnReviewPolicy>,
176    /// Stable capabilities delegated to the internal-turn reviewer.
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub turn_reviewer_capabilities: Vec<CapabilityId>,
179    /// Stable reviewer identity expected throughout recovery.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub terminal_reviewer: Option<TerminalReviewerDescriptor>,
182    /// Terminal-review repair limit expected during recovery.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub terminal_review_policy: Option<TerminalReviewPolicy>,
185    /// Stable capabilities delegated to the terminal reviewer.
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub terminal_reviewer_capabilities: Vec<CapabilityId>,
188    /// Current recovery phase.
189    pub phase: AgentCheckpointPhase,
190    /// Atomic conversation commit metadata, when this is a durable turn.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub durable_conversation: Option<DurableConversationCheckpoint>,
193}
194
195impl AgentCheckpointState {
196    pub(crate) fn outcome(&self) -> Option<AgentOutcome> {
197        match &self.phase {
198            AgentCheckpointPhase::Completed { response } => Some(AgentOutcome {
199                response: response.as_ref().clone(),
200                transcript: self.transcript.clone(),
201                turns: self.turns,
202                tool_calls: self.tool_calls,
203                delegations: self.delegations,
204                usage: self.usage,
205            }),
206            _ => None,
207        }
208    }
209
210    pub(crate) fn terminal_failure(&self) -> Option<AgentError> {
211        match &self.phase {
212            AgentCheckpointPhase::TerminalRequirementFailed {
213                failure, attempts, ..
214            } => Some(super::agent::completion::failure_error(failure, *attempts)),
215            AgentCheckpointPhase::TerminalReviewRejected { reason, .. } => {
216                Some(AgentError::TerminalReviewRejected {
217                    reason: reason.clone(),
218                })
219            }
220            AgentCheckpointPhase::TerminalReviewExhausted { attempts, .. } => {
221                Some(AgentError::TerminalReviewExhausted {
222                    attempts: *attempts,
223                })
224            }
225            AgentCheckpointPhase::TurnReviewRejected { reason, .. } => {
226                Some(AgentError::TurnReviewRejected {
227                    reason: reason.clone(),
228                })
229            }
230            AgentCheckpointPhase::TurnReviewExhausted { attempts, .. } => {
231                Some(AgentError::TurnReviewExhausted {
232                    attempts: *attempts,
233                })
234            }
235            _ => None,
236        }
237    }
238}
239
240/// Stable handle binding one checkpoint identity to a store.
241#[derive(Clone)]
242pub struct AgentCheckpoint {
243    id: CheckpointId,
244    store: Arc<dyn CheckpointStore>,
245}
246
247impl AgentCheckpoint {
248    /// Creates a new checkpoint handle with a unique identity.
249    pub fn new(store: Arc<dyn CheckpointStore>) -> Self {
250        Self {
251            id: CheckpointId::new(),
252            store,
253        }
254    }
255
256    /// Reconnects to an existing checkpoint identity.
257    pub fn existing(id: CheckpointId, store: Arc<dyn CheckpointStore>) -> Self {
258        Self { id, store }
259    }
260
261    /// Returns the stable checkpoint identity.
262    pub const fn id(&self) -> CheckpointId {
263        self.id
264    }
265
266    /// Loads and validates the latest typed Agent state.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`CheckpointError`] when storage or payload validation fails.
271    pub fn load(&self) -> Result<(Checkpoint, AgentCheckpointState), CheckpointError> {
272        let checkpoint = self.store.load(self.id)?;
273        if checkpoint.kind != CHECKPOINT_KIND
274            || checkpoint.schema_version != CHECKPOINT_SCHEMA_VERSION
275        {
276            return Err(CheckpointError::new(
277                CheckpointErrorKind::InvalidPayload,
278                "checkpoint kind or schema version is not supported",
279            ));
280        }
281        let state = serde_json::from_value(checkpoint.payload.clone()).map_err(|error| {
282            CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
283        })?;
284        Ok((checkpoint, state))
285    }
286}
287
288impl fmt::Debug for AgentCheckpoint {
289    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
290        formatter
291            .debug_struct("AgentCheckpoint")
292            .field("id", &self.id)
293            .finish_non_exhaustive()
294    }
295}
296
297pub(crate) struct CheckpointCursor {
298    handle: AgentCheckpoint,
299    envelope: Checkpoint,
300}
301
302impl CheckpointCursor {
303    pub(crate) fn create(
304        handle: &AgentCheckpoint,
305        run: &RunContext,
306        state: &AgentCheckpointState,
307    ) -> Result<Self, AgentError> {
308        let payload = serialize(state)?;
309        let envelope = Checkpoint::initial(
310            handle.id,
311            run.run_id(),
312            CHECKPOINT_KIND,
313            CHECKPOINT_SCHEMA_VERSION,
314            payload,
315        );
316        handle.store.compare_and_swap(&envelope, None)?;
317        Ok(Self {
318            handle: handle.clone(),
319            envelope,
320        })
321    }
322
323    pub(crate) fn loaded(handle: &AgentCheckpoint, envelope: Checkpoint) -> Self {
324        Self {
325            handle: handle.clone(),
326            envelope,
327        }
328    }
329
330    pub(crate) fn save(&mut self, state: &AgentCheckpointState) -> Result<(), AgentError> {
331        let next = self.envelope.next(serialize(state)?)?;
332        self.handle
333            .store
334            .compare_and_swap(&next, Some(self.envelope.revision))?;
335        self.envelope = next;
336        Ok(())
337    }
338
339    pub(crate) fn next(&self, state: &AgentCheckpointState) -> Result<Checkpoint, AgentError> {
340        self.envelope.next(serialize(state)?).map_err(Into::into)
341    }
342
343    pub(crate) const fn revision(&self) -> u64 {
344        self.envelope.revision
345    }
346
347    pub(crate) const fn id(&self) -> CheckpointId {
348        self.envelope.id
349    }
350}
351
352fn serialize(state: &AgentCheckpointState) -> Result<serde_json::Value, AgentError> {
353    serde_json::to_value(state).map_err(|error| {
354        CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string()).into()
355    })
356}