Skip to main content

lc_agents/
resume.rs

1// lc-agents/src/resume.rs
2//! Cross-process resume (§4.2 approval/budget gate): suspend-state persistence + recovery.
3//!
4//! The approval gate's [`ApprovalHandler`](crate::approval::ApprovalHandler)
5//! `approve` is purely async — suspending a future to wait for an approval
6//! signal works naturally within one process, but **a process death loses the
7//! suspension point**: if the process is killed while waiting for approval, the
8//! signal never arrives and the agent loop cannot continue. This module
9//! serializes "the tool call awaiting approval + the context needed to resume
10//! the agent loop" to disk, so a restarted process resumes from the checkpoint
11//! instead of replaying the whole conversation.
12//!
13//! Division of labor:
14//!
15//! - [`PendingApproval`]: the pending tool + a snapshot of inputs /
16//!   intermediate steps / iteration / budget totals, `Serialize + Deserialize`.
17//! - [`ResumeStore`]: checkpoint persistence interface.
18//! - [`FileResumeStore`]: disk implementation (JSON + atomic write), for real
19//!   cross-process recovery.
20//! - [`MemoryResumeStore`]: in-memory implementation, for tests / single-process
21//!   demos.
22//!
23//! Framework integration point (see `executor/agent_loop.rs::execute_tool_inner`):
24//! before each tool call enters the approval gate to wait for approval, the
25//! [`PendingApproval`] is written to the store; it is cleared once the approval
26//! decision is finalized. On a crash the checkpoint stays on disk; a new process
27//! inspects it via [`AgentExecutor::pending_approval`] and resumes via
28//! [`AgentExecutor::resume`].
29//!
30//! Recovery (process B):
31//!
32//! ```rust,ignore
33//! // Process B: rebuild the same executor as process A (same agent / tools / store dir).
34//! let store = Arc::new(FileResumeStore::new("/var/checkpoints/app")?);
35//! let executor = AgentExecutor::new(agent, tools)
36//!     .with_resume_store(store)
37//!     .with_approval(handler);
38//!
39//! if let Some(pending) = executor.pending_approval().await? {
40//!     // Show the operator pending.tool_name / pending.arguments and collect the decision.
41//!     let answer = executor.resume(decision).await?;
42//! }
43//! ```
44
45use async_trait::async_trait;
46use serde::{Deserialize, Serialize};
47use std::collections::HashMap;
48use std::path::PathBuf;
49
50use crate::types::AgentStep;
51
52/// Cross-process resume error.
53#[derive(Debug, thiserror::Error)]
54#[non_exhaustive]
55pub enum ResumeError {
56    /// Filesystem I/O error (create dir / read / write / delete).
57    #[error("resume store I/O error: {0}")]
58    Io(String),
59    /// Checkpoint serialization / deserialization error.
60    #[error("resume store serialization error: {0}")]
61    Serialize(String),
62}
63
64/// The tool call awaiting approval + the context snapshot needed to resume the agent loop.
65///
66/// The framework persists it inside `execute_tool`, **before** calling the
67/// approval handler (at that point the sync hooks have finished and `tool_name` /
68/// `arguments` are the final values the approval actually sees); it is cleared
69/// once the approval decision is finalized. On a process crash the checkpoint
70/// stays on disk; a new process loads it and re-enters the approval flow (or
71/// resumes directly with the given decision) instead of replaying the completed
72/// intermediate steps from scratch.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PendingApproval {
75    /// Tool name awaiting approval (final value after sync hooks).
76    pub tool_name: String,
77    /// Tool arguments awaiting approval (JSON; final value after sync hooks).
78    pub arguments: serde_json::Value,
79    /// Tool call id (function-calling style; empty string if none).
80    pub tool_id: String,
81    /// Input variables to resume the agent loop (prompt variables, including `input`).
82    pub inputs: HashMap<String, String>,
83    /// Completed intermediate steps (all prior tool actions + observations). Recovery continues from here without replaying.
84    pub steps: Vec<AgentStep>,
85    /// Iteration number at suspension. Recovery continues from this iteration so the iteration budget stays unbroken.
86    pub iteration: usize,
87    /// Budget: tool calls consumed so far (including the pending tool).
88    pub tool_calls_consumed: usize,
89    /// Budget: LLM tokens consumed (None if the agent does not report).
90    pub tokens_consumed: Option<usize>,
91    /// Original run trace_id (reused after recovery to keep tracing continuous).
92    pub trace_id: Option<String>,
93}
94
95/// Checkpoint storage interface.
96///
97/// The framework calls [`save_pending`](Self::save_pending) /
98/// [`clear_pending`](Self::clear_pending) around the approval handler; a new
99/// process reads the checkpoint via [`load_pending`](Self::load_pending).
100/// Implementations must be `Send + Sync` (shared across tasks / processes).
101#[async_trait]
102pub trait ResumeStore: Send + Sync {
103    /// Persists a pending-approval checkpoint.
104    async fn save_pending(&self, pending: &PendingApproval) -> Result<(), ResumeError>;
105    /// Reads the current checkpoint; returns `None` if absent.
106    async fn load_pending(&self) -> Result<Option<PendingApproval>, ResumeError>;
107    /// Clears the checkpoint (decision finalized / checkpoint claimed). Treats absence as success.
108    async fn clear_pending(&self) -> Result<(), ResumeError>;
109}
110
111/// Disk checkpoint storage: JSON persistence + atomic write.
112///
113/// Atomic write: write `pending.json.tmp` first, then `rename`, so a crash never
114/// leaves a partial checkpoint. The directory is chosen by the caller;
115/// **concurrent executors must use separate directories** to avoid overwriting
116/// each other's checkpoints.
117pub struct FileResumeStore {
118    dir: PathBuf,
119}
120
121impl FileResumeStore {
122    /// Creates a checkpoint store; auto-creates the directory (and parents) if missing.
123    pub fn new(dir: impl Into<PathBuf>) -> Result<Self, ResumeError> {
124        let dir = dir.into();
125        std::fs::create_dir_all(&dir)
126            .map_err(|e| ResumeError::Io(format!("create dir {}: {}", dir.display(), e)))?;
127        Ok(Self { dir })
128    }
129
130    fn pending_path(&self) -> PathBuf {
131        self.dir.join("pending.json")
132    }
133}
134
135#[async_trait]
136impl ResumeStore for FileResumeStore {
137    async fn save_pending(&self, pending: &PendingApproval) -> Result<(), ResumeError> {
138        let bytes = serde_json::to_vec_pretty(pending)
139            .map_err(|e| ResumeError::Serialize(e.to_string()))?;
140        let tmp = self.dir.join("pending.json.tmp");
141        tokio::fs::write(&tmp, &bytes)
142            .await
143            .map_err(|e| ResumeError::Io(format!("write {}: {}", tmp.display(), e)))?;
144        tokio::fs::rename(&tmp, self.pending_path())
145            .await
146            .map_err(|e| ResumeError::Io(format!("rename {}: {}", tmp.display(), e)))?;
147        Ok(())
148    }
149
150    async fn load_pending(&self) -> Result<Option<PendingApproval>, ResumeError> {
151        let path = self.pending_path();
152        let bytes = match tokio::fs::read(&path).await {
153            Ok(bytes) => bytes,
154            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
155            Err(e) => {
156                return Err(ResumeError::Io(format!("read {}: {}", path.display(), e)));
157            }
158        };
159        let pending = serde_json::from_slice(&bytes)
160            .map_err(|e| ResumeError::Serialize(format!("parse {}: {}", path.display(), e)))?;
161        Ok(Some(pending))
162    }
163
164    async fn clear_pending(&self) -> Result<(), ResumeError> {
165        let path = self.pending_path();
166        match tokio::fs::remove_file(&path).await {
167            Ok(()) => Ok(()),
168            // Checkpoint already absent: idempotent clear, treated as success.
169            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
170            Err(e) => Err(ResumeError::Io(format!("remove {}: {}", path.display(), e))),
171        }
172    }
173}
174
175/// In-memory checkpoint store (for tests / single-process demos; not persistent across process death).
176#[derive(Default)]
177pub struct MemoryResumeStore {
178    pending: tokio::sync::Mutex<Option<PendingApproval>>,
179}
180
181impl MemoryResumeStore {
182    /// Creates an empty in-memory checkpoint store.
183    pub fn new() -> Self {
184        Self::default()
185    }
186}
187
188#[async_trait]
189impl ResumeStore for MemoryResumeStore {
190    async fn save_pending(&self, pending: &PendingApproval) -> Result<(), ResumeError> {
191        *self.pending.lock().await = Some(pending.clone());
192        Ok(())
193    }
194
195    async fn load_pending(&self) -> Result<Option<PendingApproval>, ResumeError> {
196        Ok(self.pending.lock().await.clone())
197    }
198
199    async fn clear_pending(&self) -> Result<(), ResumeError> {
200        *self.pending.lock().await = None;
201        Ok(())
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::types::{AgentAction, ToolInput};
209
210    fn sample_pending() -> PendingApproval {
211        let mut inputs = HashMap::new();
212        inputs.insert("input".to_string(), "compute".to_string());
213        PendingApproval {
214            tool_name: "calculator".to_string(),
215            arguments: serde_json::json!({"expression": "2 + 3"}),
216            tool_id: "call_1".to_string(),
217            inputs,
218            steps: vec![AgentStep::new(
219                AgentAction {
220                    tool: "other".to_string(),
221                    tool_input: ToolInput::String {
222                        value: "x".to_string(),
223                    },
224                    log: String::new(),
225                },
226                "obs".to_string(),
227            )],
228            iteration: 3,
229            tool_calls_consumed: 4,
230            tokens_consumed: Some(128),
231            trace_id: Some("trace-1".to_string()),
232        }
233    }
234
235    #[tokio::test]
236    async fn test_file_store_roundtrip() {
237        let dir = tempfile::tempdir().unwrap();
238        let store = FileResumeStore::new(dir.path()).unwrap();
239
240        // Empty directory: no checkpoint.
241        assert!(store.load_pending().await.unwrap().is_none());
242
243        let pending = sample_pending();
244        store.save_pending(&pending).await.unwrap();
245        let loaded = store.load_pending().await.unwrap().unwrap();
246        assert_eq!(loaded.tool_name, "calculator");
247        assert_eq!(loaded.arguments, serde_json::json!({"expression": "2 + 3"}));
248        assert_eq!(loaded.iteration, 3);
249        assert_eq!(loaded.tool_calls_consumed, 4);
250        assert_eq!(loaded.tokens_consumed, Some(128));
251        assert_eq!(loaded.inputs.get("input").unwrap(), "compute");
252        assert_eq!(loaded.steps.len(), 1);
253        assert_eq!(loaded.trace_id.as_deref(), Some("trace-1"));
254
255        store.clear_pending().await.unwrap();
256        assert!(store.load_pending().await.unwrap().is_none());
257    }
258
259    #[tokio::test]
260    async fn test_file_store_clear_idempotent() {
261        let dir = tempfile::tempdir().unwrap();
262        let store = FileResumeStore::new(dir.path()).unwrap();
263        // clear with no checkpoint does not error (idempotent).
264        store.clear_pending().await.unwrap();
265        store.clear_pending().await.unwrap();
266    }
267
268    #[tokio::test]
269    async fn test_file_store_atomic_no_tmp_left() {
270        let dir = tempfile::tempdir().unwrap();
271        let store = FileResumeStore::new(dir.path()).unwrap();
272        store.save_pending(&sample_pending()).await.unwrap();
273
274        // Atomic write: no .tmp leftover after a successful save.
275        let entries: Vec<String> = std::fs::read_dir(dir.path())
276            .unwrap()
277            .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
278            .collect();
279        assert!(
280            !entries.iter().any(|n| n == "pending.json.tmp"),
281            "tmp file should be renamed away, got {entries:?}"
282        );
283        assert!(entries.contains(&"pending.json".to_string()));
284    }
285
286    #[tokio::test]
287    async fn test_memory_store_roundtrip() {
288        let store = MemoryResumeStore::new();
289        assert!(store.load_pending().await.unwrap().is_none());
290
291        store.save_pending(&sample_pending()).await.unwrap();
292        assert!(store.load_pending().await.unwrap().is_some());
293
294        store.clear_pending().await.unwrap();
295        assert!(store.load_pending().await.unwrap().is_none());
296    }
297
298    #[test]
299    fn test_pending_approval_serde_roundtrip() {
300        let pending = sample_pending();
301        let bytes = serde_json::to_vec(&pending).unwrap();
302        let back: PendingApproval = serde_json::from_slice(&bytes).unwrap();
303        assert_eq!(back.tool_name, pending.tool_name);
304        assert_eq!(back.arguments, pending.arguments);
305        assert_eq!(back.steps.len(), pending.steps.len());
306        assert_eq!(back.trace_id, pending.trace_id);
307    }
308
309    #[test]
310    fn test_resume_error_display() {
311        let e = ResumeError::Io("disk full".to_string());
312        assert!(e.to_string().contains("disk full"));
313        let e = ResumeError::Serialize("bad json".to_string());
314        assert!(e.to_string().contains("bad json"));
315    }
316
317    /// Convenience assertion: `FileResumeStore::new` auto-creates the directory.
318    #[tokio::test]
319    async fn test_file_store_creates_dir() {
320        let dir = tempfile::tempdir().unwrap();
321        let nested = dir.path().join("a").join("b");
322        let store = FileResumeStore::new(&nested).unwrap();
323        assert!(nested.is_dir());
324        assert!(store.load_pending().await.is_ok());
325    }
326}