agent_code_lib/tools/
notebook_edit.rs1use async_trait::async_trait;
7use serde_json::json;
8use std::path::PathBuf;
9
10use super::{Tool, ToolContext, ToolResult};
11use crate::error::ToolError;
12
13pub struct NotebookEditTool;
14
15#[async_trait]
16impl Tool for NotebookEditTool {
17 fn name(&self) -> &'static str {
18 "NotebookEdit"
19 }
20
21 fn description(&self) -> &'static str {
22 "Edit cells in Jupyter notebooks (.ipynb files). Can replace cell \
23 content, insert new cells, or delete cells."
24 }
25
26 fn input_schema(&self) -> serde_json::Value {
27 json!({
28 "type": "object",
29 "required": ["file_path", "edit"],
30 "properties": {
31 "file_path": {
32 "type": "string",
33 "description": "Path to the .ipynb file"
34 },
35 "edit": {
36 "type": "object",
37 "description": "The edit to apply",
38 "properties": {
39 "action": {
40 "type": "string",
41 "enum": ["replace", "insert", "delete"],
42 "description": "Edit action"
43 },
44 "cell_index": {
45 "type": "integer",
46 "description": "Index of the cell to edit (0-based)"
47 },
48 "cell_type": {
49 "type": "string",
50 "enum": ["code", "markdown"],
51 "description": "Cell type (for insert)"
52 },
53 "content": {
54 "type": "string",
55 "description": "New cell content"
56 }
57 }
58 }
59 }
60 })
61 }
62
63 fn is_read_only(&self) -> bool {
64 false
65 }
66
67 fn get_path(&self, input: &serde_json::Value) -> Option<PathBuf> {
68 input
69 .get("file_path")
70 .and_then(|v| v.as_str())
71 .map(PathBuf::from)
72 }
73
74 async fn call(
75 &self,
76 input: serde_json::Value,
77 _ctx: &ToolContext,
78 ) -> Result<ToolResult, ToolError> {
79 let file_path = input
80 .get("file_path")
81 .and_then(|v| v.as_str())
82 .ok_or_else(|| ToolError::InvalidInput("'file_path' is required".into()))?;
83
84 let edit = input
85 .get("edit")
86 .ok_or_else(|| ToolError::InvalidInput("'edit' is required".into()))?;
87
88 let action = edit
89 .get("action")
90 .and_then(|v| v.as_str())
91 .ok_or_else(|| ToolError::InvalidInput("'edit.action' is required".into()))?;
92
93 let content = tokio::fs::read_to_string(file_path)
95 .await
96 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to read {file_path}: {e}")))?;
97
98 let mut notebook: serde_json::Value = serde_json::from_str(&content)
99 .map_err(|e| ToolError::ExecutionFailed(format!("Invalid notebook JSON: {e}")))?;
100
101 let cells = notebook
102 .get_mut("cells")
103 .and_then(|v| v.as_array_mut())
104 .ok_or_else(|| ToolError::ExecutionFailed("Notebook has no 'cells' array".into()))?;
105
106 match action {
107 "replace" => {
108 let idx = edit
109 .get("cell_index")
110 .and_then(|v| v.as_u64())
111 .ok_or_else(|| {
112 ToolError::InvalidInput("'cell_index' required for replace".into())
113 })? as usize;
114
115 let new_content =
116 edit.get("content")
117 .and_then(|v| v.as_str())
118 .ok_or_else(|| {
119 ToolError::InvalidInput("'content' required for replace".into())
120 })?;
121
122 if idx >= cells.len() {
123 return Err(ToolError::InvalidInput(format!(
124 "Cell index {idx} out of range (notebook has {} cells)",
125 cells.len()
126 )));
127 }
128
129 let source_lines: Vec<serde_json::Value> = new_content
131 .lines()
132 .map(|l| serde_json::Value::String(format!("{l}\n")))
133 .collect();
134 cells[idx]["source"] = serde_json::Value::Array(source_lines);
135
136 if cells[idx].get("cell_type").and_then(|v| v.as_str()) == Some("code") {
138 cells[idx]["outputs"] = serde_json::Value::Array(vec![]);
139 cells[idx]["execution_count"] = serde_json::Value::Null;
140 }
141 }
142 "insert" => {
143 let idx = edit
144 .get("cell_index")
145 .and_then(|v| v.as_u64())
146 .unwrap_or(cells.len() as u64) as usize;
147
148 let cell_type = edit
149 .get("cell_type")
150 .and_then(|v| v.as_str())
151 .unwrap_or("code");
152
153 let new_content = edit.get("content").and_then(|v| v.as_str()).unwrap_or("");
154
155 let source_lines: Vec<serde_json::Value> = new_content
156 .lines()
157 .map(|l| serde_json::Value::String(format!("{l}\n")))
158 .collect();
159
160 let new_cell = if cell_type == "code" {
161 json!({
162 "cell_type": "code",
163 "source": source_lines,
164 "metadata": {},
165 "outputs": [],
166 "execution_count": null
167 })
168 } else {
169 json!({
170 "cell_type": "markdown",
171 "source": source_lines,
172 "metadata": {}
173 })
174 };
175
176 let idx = idx.min(cells.len());
177 cells.insert(idx, new_cell);
178 }
179 "delete" => {
180 let idx = edit
181 .get("cell_index")
182 .and_then(|v| v.as_u64())
183 .ok_or_else(|| {
184 ToolError::InvalidInput("'cell_index' required for delete".into())
185 })? as usize;
186
187 if idx >= cells.len() {
188 return Err(ToolError::InvalidInput(format!(
189 "Cell index {idx} out of range (notebook has {} cells)",
190 cells.len()
191 )));
192 }
193
194 cells.remove(idx);
195 }
196 other => {
197 return Err(ToolError::InvalidInput(format!(
198 "Unknown action '{other}'. Use 'replace', 'insert', or 'delete'."
199 )));
200 }
201 }
202
203 let output = serde_json::to_string_pretty(¬ebook)
205 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to serialize: {e}")))?;
206
207 tokio::fs::write(file_path, &output)
208 .await
209 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to write {file_path}: {e}")))?;
210
211 Ok(ToolResult::success(format!(
212 "Notebook {file_path}: {action} applied ({} cells total)",
213 notebook["cells"].as_array().map(|c| c.len()).unwrap_or(0)
214 )))
215 }
216}