Skip to main content

oxi_agent/tools/
checkpoint_tool.rs

1/// Checkpoint and Rewind tools — save/restore investigation state.
2///
3/// CheckpointTool saves a snapshot of the current investigation goal.
4/// RewindTool restores to a previous checkpoint by accepting a findings report.
5/// State is stored in-memory (process-local, not persisted).
6use super::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode, ToolTier};
7use async_trait::async_trait;
8use serde_json::{Value, json};
9use std::sync::LazyLock;
10use std::sync::Mutex;
11use tokio::sync::oneshot;
12
13/// Internal checkpoint state.
14#[derive(Clone)]
15struct CheckpointState {
16    goal: String,
17}
18
19/// Global checkpoint store (process-local).
20static CHECKPOINT: LazyLock<Mutex<Option<CheckpointState>>> = LazyLock::new(|| Mutex::new(None));
21
22/// CheckpointTool — create a checkpoint with an investigation goal.
23pub struct CheckpointTool;
24
25#[async_trait]
26impl AgentTool for CheckpointTool {
27    fn name(&self) -> &str {
28        "checkpoint"
29    }
30
31    fn label(&self) -> &str {
32        "Checkpoint"
33    }
34
35    fn description(&self) -> &str {
36        concat!(
37            "Create an investigation checkpoint to save and restore session state. ",
38            "Call with a goal describing what you are investigating. ",
39            "After completing the investigation, use the rewind tool with a report of findings."
40        )
41    }
42
43    fn essential(&self) -> bool {
44        false
45    }
46
47    fn parameters_schema(&self) -> Value {
48        json!({
49            "type": "object",
50            "properties": {
51                "goal": {
52                    "type": "string",
53                    "description": "Investigation goal — what you plan to find out."
54                }
55            },
56            "required": ["goal"]
57        })
58    }
59
60    fn intent(&self) -> Option<&str> {
61        Some("Save investigation checkpoint")
62    }
63
64    fn execution_mode(&self) -> ToolExecutionMode {
65        ToolExecutionMode::SequentialOnly
66    }
67
68    fn tool_tier(&self) -> ToolTier {
69        ToolTier::Read
70    }
71
72    async fn execute(
73        &self,
74        _tool_call_id: &str,
75        params: Value,
76        _signal: Option<oneshot::Receiver<()>>,
77        _ctx: &ToolContext,
78    ) -> Result<AgentToolResult, ToolError> {
79        let goal = params
80            .get("goal")
81            .and_then(|v| v.as_str())
82            .map(|s| s.to_string())
83            .ok_or_else(|| "Missing required parameter: goal".to_string())?;
84
85        let mut store = CHECKPOINT
86            .lock()
87            .map_err(|e| format!("Checkpoint lock error: {}", e))?;
88
89        if store.is_some() {
90            return Err(
91                "Checkpoint already active. Use rewind to close the current checkpoint first."
92                    .to_string(),
93            );
94        }
95
96        *store = Some(CheckpointState { goal: goal.clone() });
97
98        let output = format!(
99            "Checkpoint created.\nGoal: {}\nRun your investigation, then call rewind with a concise report.",
100            goal
101        );
102        Ok(AgentToolResult::success(output))
103    }
104}
105
106/// RewindTool — close a checkpoint and submit investigation findings.
107pub struct RewindTool;
108
109#[async_trait]
110impl AgentTool for RewindTool {
111    fn name(&self) -> &str {
112        "rewind"
113    }
114
115    fn label(&self) -> &str {
116        "Rewind"
117    }
118
119    fn description(&self) -> &str {
120        concat!(
121            "Close an active investigation checkpoint and submit findings. ",
122            "Provide a concise report of what you discovered. ",
123            "Must be called after a checkpoint is active."
124        )
125    }
126
127    fn essential(&self) -> bool {
128        false
129    }
130
131    fn parameters_schema(&self) -> Value {
132        json!({
133            "type": "object",
134            "properties": {
135                "report": {
136                    "type": "string",
137                    "description": "Investigation findings report — concise summary of what was discovered."
138                }
139            },
140            "required": ["report"]
141        })
142    }
143
144    fn intent(&self) -> Option<&str> {
145        Some("Close investigation checkpoint with findings")
146    }
147
148    fn execution_mode(&self) -> ToolExecutionMode {
149        ToolExecutionMode::SequentialOnly
150    }
151
152    fn tool_tier(&self) -> ToolTier {
153        ToolTier::Read
154    }
155
156    async fn execute(
157        &self,
158        _tool_call_id: &str,
159        params: Value,
160        _signal: Option<oneshot::Receiver<()>>,
161        _ctx: &ToolContext,
162    ) -> Result<AgentToolResult, ToolError> {
163        let report = params
164            .get("report")
165            .and_then(|v| v.as_str())
166            .map(|s| s.to_string())
167            .ok_or_else(|| "Missing required parameter: report".to_string())?;
168
169        let mut store = CHECKPOINT
170            .lock()
171            .map_err(|e| format!("Checkpoint lock error: {}", e))?;
172
173        let state = store
174            .take()
175            .ok_or_else(|| "No active checkpoint to rewind.".to_string())?;
176
177        Ok(AgentToolResult::success(format!(
178            "Checkpoint closed.\nGoal: {}\nReport: {}\n\nCheckpoint rewound successfully.",
179            state.goal, report
180        )))
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[tokio::test]
189    async fn test_checkpoint_create_and_rewind() {
190        // Reset global state
191        *CHECKPOINT.lock().unwrap() = None;
192
193        let tool = CheckpointTool;
194        let params = json!({"goal": "Investigate authentication flow"});
195        let result = tool
196            .execute("id", params, None, &ToolContext::default())
197            .await
198            .unwrap();
199        assert!(result.success);
200        assert!(result.output.contains("Checkpoint created"));
201
202        // Second checkpoint should fail
203        let params2 = json!({"goal": "Another goal"});
204        let result2 = tool
205            .execute("id", params2, None, &ToolContext::default())
206            .await;
207        assert!(result2.is_err());
208
209        // Rewind
210        let rewind = RewindTool;
211        let params3 = json!({"report": "Found auth token rotation issue"});
212        let result3 = rewind
213            .execute("id", params3.clone(), None, &ToolContext::default())
214            .await
215            .unwrap();
216        assert!(result3.success);
217        assert!(result3.output.contains("Checkpoint closed"));
218
219        // Rewind without checkpoint should fail
220        let result4 = rewind
221            .execute("id", params3, None, &ToolContext::default())
222            .await;
223        assert!(result4.is_err());
224    }
225}