oxi_agent/tools/
checkpoint_tool.rs1use 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#[derive(Clone)]
15struct CheckpointState {
16 goal: String,
17}
18
19static CHECKPOINT: LazyLock<Mutex<Option<CheckpointState>>> = LazyLock::new(|| Mutex::new(None));
21
22pub 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
106pub 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 *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 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 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 let result4 = rewind
221 .execute("id", params3, None, &ToolContext::default())
222 .await;
223 assert!(result4.is_err());
224 }
225}