Skip to main content

bamboo_tools/tools/
write.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5use std::path::Path;
6
7use super::read_tracker::ReadState;
8use super::{content_diagnostics, file_change, read_tracker};
9
10#[derive(Debug, Deserialize)]
11struct WriteArgs {
12    file_path: String,
13    content: String,
14}
15
16pub struct WriteTool;
17
18impl WriteTool {
19    pub fn new() -> Self {
20        Self
21    }
22}
23
24impl Default for WriteTool {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30#[async_trait]
31impl Tool for WriteTool {
32    fn name(&self) -> &str {
33        "Write"
34    }
35
36    fn description(&self) -> &str {
37        "Write a local file (create or replace full content). IMPORTANT: for existing files, call Read first in this session or Write will fail."
38    }
39
40    fn parameters_schema(&self) -> serde_json::Value {
41        json!({
42            "type": "object",
43            "properties": {
44                "file_path": {
45                    "type": "string",
46                    "description": "The absolute path to the file to write"
47                },
48                "content": {
49                    "type": "string",
50                    "description": "The content to write to the file"
51                }
52            },
53            "required": ["file_path", "content"],
54            "additionalProperties": false
55        })
56    }
57
58    async fn invoke(
59        &self,
60        args: serde_json::Value,
61        ctx: ToolCtx,
62    ) -> Result<ToolOutcome, ToolError> {
63        let parsed: WriteArgs = serde_json::from_value(args)
64            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Write args: {}", e)))?;
65
66        let file_path = parsed.file_path.trim();
67        let path = Path::new(file_path);
68
69        if !path.is_absolute() {
70            return Err(ToolError::InvalidArguments(
71                "file_path must be an absolute path".to_string(),
72            ));
73        }
74
75        if path.exists() {
76            if let Some(session_id) = ctx.session_id() {
77                match read_tracker::read_state(session_id, file_path).await {
78                    ReadState::Unread => {
79                        return Err(ToolError::Execution(
80                            "Write requires reading the target file first via Read".to_string(),
81                        ));
82                    }
83                    ReadState::Stale => {
84                        return Err(ToolError::Execution(
85                            "Target file changed after last Read; call Read again before Write"
86                                .to_string(),
87                        ));
88                    }
89                    ReadState::Fresh => {}
90                }
91            }
92        }
93
94        let previous_bytes = file_change::read_existing_bytes(path).await?;
95        let checkpoint = file_change::create_checkpoint(path, previous_bytes.as_deref()).await?;
96        let next_content = parsed.content;
97
98        file_change::atomic_write_text(path, &next_content).await?;
99
100        let previous_text = file_change::bytes_to_lossy_text(previous_bytes.as_deref());
101        let mut payload = file_change::build_file_change_payload_value(
102            "Write",
103            path,
104            format!("Wrote file: {}", file_path),
105            checkpoint,
106            &previous_text,
107            &next_content,
108        );
109        content_diagnostics::attach_file_diagnostics(&mut payload, path, &next_content);
110
111        Ok(ToolOutcome::Completed(ToolResult {
112            success: true,
113            result: payload.to_string(),
114            display_preference: Some("Default".to_string()),
115            images: Vec::new(),
116        }))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::tools::ReadTool;
124    use serde_json::json;
125
126    fn ctx(session_id: &str) -> ToolCtx {
127        ToolCtx {
128            session_id: Some(std::sync::Arc::from(session_id)),
129            tool_call_id: std::sync::Arc::from("call_1"),
130            event_tx: None,
131            available_tool_schemas: std::sync::Arc::from(Vec::new()),
132            bypass_permissions: false,
133            auto_approve_permissions: false,
134            plan_read_only: false,
135            can_async_resume: false,
136            async_completion_sink: None,
137            bash_completion_sink: None,
138        }
139    }
140
141    #[tokio::test]
142    async fn write_requires_fresh_read_for_existing_files() {
143        let file = tempfile::NamedTempFile::new().unwrap();
144        tokio::fs::write(file.path(), "v1").await.unwrap();
145        let write_tool = WriteTool::new();
146        let read_tool = ReadTool::new();
147
148        let denied = write_tool
149            .invoke(
150                json!({"file_path": file.path(), "content": "v2"}),
151                ctx("session_a"),
152            )
153            .await;
154        assert!(matches!(denied, Err(ToolError::Execution(_))));
155
156        let _ = read_tool
157            .invoke(json!({"file_path": file.path()}), ctx("session_a"))
158            .await
159            .unwrap();
160
161        tokio::fs::write(file.path(), "external change")
162            .await
163            .unwrap();
164
165        let stale = write_tool
166            .invoke(
167                json!({"file_path": file.path(), "content": "v3"}),
168                ctx("session_a"),
169            )
170            .await;
171        assert!(matches!(stale, Err(ToolError::Execution(msg)) if msg.contains("changed")));
172
173        let _ = read_tool
174            .invoke(json!({"file_path": file.path()}), ctx("session_a"))
175            .await
176            .unwrap();
177        let out = write_tool
178            .invoke(
179                json!({"file_path": file.path(), "content": "final"}),
180                ctx("session_a"),
181            )
182            .await
183            .unwrap();
184        let ToolOutcome::Completed(ok) = out else {
185            panic!("expected Completed")
186        };
187        assert!(ok.success);
188    }
189
190    #[cfg(unix)]
191    #[tokio::test]
192    async fn write_rejects_symlinked_path_components() {
193        use std::os::unix::fs::symlink;
194        let dir = tempfile::tempdir().unwrap();
195        let real = dir.path().join("real");
196        let link = dir.path().join("link");
197        tokio::fs::create_dir_all(&real).await.unwrap();
198        symlink(&real, &link).unwrap();
199
200        let write_tool = WriteTool::new();
201        let result = write_tool
202            .invoke(
203                json!({
204                    "file_path": link.join("test.txt"),
205                    "content": "hello"
206                }),
207                ToolCtx::none("t"),
208            )
209            .await;
210        assert!(matches!(result, Err(ToolError::Execution(msg)) if msg.contains("symlinked")));
211    }
212
213    #[tokio::test]
214    async fn write_includes_json_diagnostics_for_invalid_content() {
215        let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
216        let write_tool = WriteTool::new();
217
218        let out = write_tool
219            .invoke(
220                json!({
221                    "file_path": file.path(),
222                    "content": "{"
223                }),
224                ToolCtx::none("t"),
225            )
226            .await
227            .unwrap();
228        let ToolOutcome::Completed(result) = out else {
229            panic!("expected Completed")
230        };
231
232        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
233        assert_eq!(payload["diagnostics"]["format"], "json");
234        assert_eq!(payload["diagnostics"]["valid"], false);
235    }
236}