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/// Declarative contracts bound to a resumable execution.
152///
153/// Custom implementations must update their descriptor versions when behavior
154/// changes. This snapshot does not identify compiled code or middleware closures.
155#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
156pub struct AgentRecoveryContract {
157    pub(crate) instructions: Vec<Message>,
158    pub(crate) context: Vec<Message>,
159    pub(crate) tools: Vec<runifold_tool::ToolDescriptor>,
160    pub(crate) agents: Vec<crate::AgentDescriptor>,
161    pub(crate) retrieval: Vec<(runifold_core::CapabilityDescriptor, usize)>,
162    pub(crate) generation: runifold_model::GenerationOptions,
163    pub(crate) output_format: runifold_model::OutputFormat,
164    pub(crate) response_mode: runifold_model::ResponseMode,
165    pub(crate) provider_tools: Vec<runifold_model::ProviderToolSpec>,
166    pub(crate) provider_options: std::collections::BTreeMap<String, Value>,
167    pub(crate) config: crate::AgentConfig,
168    pub(crate) tool_concurrency: std::num::NonZeroUsize,
169    pub(crate) min_successful_tool_calls: u32,
170    pub(crate) completion: crate::CompletionRequirement,
171    pub(crate) retry_safe_effects: bool,
172}
173
174/// Versioned Agent state stored in a domain-neutral checkpoint envelope.
175#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
176pub struct AgentCheckpointState {
177    /// Declarative execution contract. Legacy checkpoints without this field
178    /// can be inspected but cannot be resumed automatically.
179    #[serde(default)]
180    pub recovery_contract: Option<AgentRecoveryContract>,
181    /// Stable logical execution identity used for callable idempotency.
182    pub execution_id: String,
183    /// Agent identity expected during recovery.
184    pub agent: String,
185    /// Model identity expected during recovery.
186    pub model: ModelRef,
187    /// Canonical transcript at the last stable boundary.
188    pub transcript: Vec<Message>,
189    /// Completed model turns.
190    pub turns: u32,
191    /// Completed local tool attempts.
192    pub tool_calls: u32,
193    /// Completed successful delegations.
194    pub delegations: u32,
195    /// Shared usage snapshot at persistence time.
196    pub usage: Usage,
197    /// Stable internal-turn reviewer identity expected throughout recovery.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub turn_reviewer: Option<TerminalReviewerDescriptor>,
200    /// Internal-turn review scope and repair limit expected during recovery.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub turn_review_policy: Option<TurnReviewPolicy>,
203    /// Stable capabilities delegated to the internal-turn reviewer.
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub turn_reviewer_capabilities: Vec<CapabilityId>,
206    /// Stable reviewer identity expected throughout recovery.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub terminal_reviewer: Option<TerminalReviewerDescriptor>,
209    /// Terminal-review repair limit expected during recovery.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub terminal_review_policy: Option<TerminalReviewPolicy>,
212    /// Stable capabilities delegated to the terminal reviewer.
213    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214    pub terminal_reviewer_capabilities: Vec<CapabilityId>,
215    /// Current recovery phase.
216    pub phase: AgentCheckpointPhase,
217    /// Atomic conversation commit metadata, when this is a durable turn.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub durable_conversation: Option<DurableConversationCheckpoint>,
220}
221
222impl AgentCheckpointState {
223    pub(crate) fn outcome(&self) -> Option<AgentOutcome> {
224        match &self.phase {
225            AgentCheckpointPhase::Completed { response } => Some(AgentOutcome {
226                response: response.as_ref().clone(),
227                transcript: self.transcript.clone(),
228                turns: self.turns,
229                tool_calls: self.tool_calls,
230                delegations: self.delegations,
231                usage: self.usage,
232            }),
233            _ => None,
234        }
235    }
236
237    pub(crate) fn terminal_failure(&self) -> Option<AgentError> {
238        match &self.phase {
239            AgentCheckpointPhase::TerminalRequirementFailed {
240                failure, attempts, ..
241            } => Some(super::agent::completion::failure_error(failure, *attempts)),
242            AgentCheckpointPhase::TerminalReviewRejected { reason, .. } => {
243                Some(AgentError::TerminalReviewRejected {
244                    reason: reason.clone(),
245                })
246            }
247            AgentCheckpointPhase::TerminalReviewExhausted { attempts, .. } => {
248                Some(AgentError::TerminalReviewExhausted {
249                    attempts: *attempts,
250                })
251            }
252            AgentCheckpointPhase::TurnReviewRejected { reason, .. } => {
253                Some(AgentError::TurnReviewRejected {
254                    reason: reason.clone(),
255                })
256            }
257            AgentCheckpointPhase::TurnReviewExhausted { attempts, .. } => {
258                Some(AgentError::TurnReviewExhausted {
259                    attempts: *attempts,
260                })
261            }
262            _ => None,
263        }
264    }
265}
266
267/// Stable handle binding one checkpoint identity to a store.
268#[derive(Clone)]
269pub struct AgentCheckpoint {
270    id: CheckpointId,
271    store: Arc<dyn CheckpointStore>,
272}
273
274impl AgentCheckpoint {
275    /// Creates a new checkpoint handle with a unique identity.
276    pub fn new(store: Arc<dyn CheckpointStore>) -> Self {
277        Self {
278            id: CheckpointId::new(),
279            store,
280        }
281    }
282
283    /// Reconnects to an existing checkpoint identity.
284    pub fn existing(id: CheckpointId, store: Arc<dyn CheckpointStore>) -> Self {
285        Self { id, store }
286    }
287
288    /// Returns the stable checkpoint identity.
289    pub const fn id(&self) -> CheckpointId {
290        self.id
291    }
292
293    /// Loads and validates the latest typed Agent state.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`CheckpointError`] when storage or payload validation fails.
298    pub fn load(&self) -> Result<(Checkpoint, AgentCheckpointState), CheckpointError> {
299        let checkpoint = self.store.load(self.id)?;
300        if checkpoint.kind != CHECKPOINT_KIND
301            || checkpoint.schema_version != CHECKPOINT_SCHEMA_VERSION
302        {
303            return Err(CheckpointError::new(
304                CheckpointErrorKind::InvalidPayload,
305                "checkpoint kind or schema version is not supported",
306            ));
307        }
308        let state = serde_json::from_value(checkpoint.payload.clone()).map_err(|error| {
309            CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
310        })?;
311        Ok((checkpoint, state))
312    }
313}
314
315impl fmt::Debug for AgentCheckpoint {
316    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
317        formatter
318            .debug_struct("AgentCheckpoint")
319            .field("id", &self.id)
320            .finish_non_exhaustive()
321    }
322}
323
324pub(crate) struct CheckpointCursor {
325    handle: AgentCheckpoint,
326    envelope: Checkpoint,
327}
328
329impl CheckpointCursor {
330    pub(crate) fn create(
331        handle: &AgentCheckpoint,
332        run: &RunContext,
333        state: &AgentCheckpointState,
334    ) -> Result<Self, AgentError> {
335        let payload = serialize(state)?;
336        let envelope = Checkpoint::initial(
337            handle.id,
338            run.run_id(),
339            CHECKPOINT_KIND,
340            CHECKPOINT_SCHEMA_VERSION,
341            payload,
342        );
343        handle.store.compare_and_swap(&envelope, None)?;
344        Ok(Self {
345            handle: handle.clone(),
346            envelope,
347        })
348    }
349
350    pub(crate) fn loaded(handle: &AgentCheckpoint, envelope: Checkpoint) -> Self {
351        Self {
352            handle: handle.clone(),
353            envelope,
354        }
355    }
356
357    pub(crate) fn save(&mut self, state: &AgentCheckpointState) -> Result<(), AgentError> {
358        let next = self.envelope.next(serialize(state)?)?;
359        self.handle
360            .store
361            .compare_and_swap(&next, Some(self.envelope.revision))?;
362        self.envelope = next;
363        Ok(())
364    }
365
366    pub(crate) fn next(&self, state: &AgentCheckpointState) -> Result<Checkpoint, AgentError> {
367        self.envelope.next(serialize(state)?).map_err(Into::into)
368    }
369
370    pub(crate) const fn revision(&self) -> u64 {
371        self.envelope.revision
372    }
373
374    pub(crate) const fn id(&self) -> CheckpointId {
375        self.envelope.id
376    }
377}
378
379fn serialize(state: &AgentCheckpointState) -> Result<serde_json::Value, AgentError> {
380    serde_json::to_value(state).map_err(|error| {
381        CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string()).into()
382    })
383}