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            can_async_resume: false,
134            async_completion_sink: None,
135            bash_completion_sink: None,
136        }
137    }
138
139    #[tokio::test]
140    async fn write_requires_fresh_read_for_existing_files() {
141        let file = tempfile::NamedTempFile::new().unwrap();
142        tokio::fs::write(file.path(), "v1").await.unwrap();
143        let write_tool = WriteTool::new();
144        let read_tool = ReadTool::new();
145
146        let denied = write_tool
147            .invoke(
148                json!({"file_path": file.path(), "content": "v2"}),
149                ctx("session_a"),
150            )
151            .await;
152        assert!(matches!(denied, Err(ToolError::Execution(_))));
153
154        let _ = read_tool
155            .invoke(json!({"file_path": file.path()}), ctx("session_a"))
156            .await
157            .unwrap();
158
159        tokio::fs::write(file.path(), "external change")
160            .await
161            .unwrap();
162
163        let stale = write_tool
164            .invoke(
165                json!({"file_path": file.path(), "content": "v3"}),
166                ctx("session_a"),
167            )
168            .await;
169        assert!(matches!(stale, Err(ToolError::Execution(msg)) if msg.contains("changed")));
170
171        let _ = read_tool
172            .invoke(json!({"file_path": file.path()}), ctx("session_a"))
173            .await
174            .unwrap();
175        let out = write_tool
176            .invoke(
177                json!({"file_path": file.path(), "content": "final"}),
178                ctx("session_a"),
179            )
180            .await
181            .unwrap();
182        let ToolOutcome::Completed(ok) = out else {
183            panic!("expected Completed")
184        };
185        assert!(ok.success);
186    }
187
188    #[cfg(unix)]
189    #[tokio::test]
190    async fn write_rejects_symlinked_path_components() {
191        use std::os::unix::fs::symlink;
192        let dir = tempfile::tempdir().unwrap();
193        let real = dir.path().join("real");
194        let link = dir.path().join("link");
195        tokio::fs::create_dir_all(&real).await.unwrap();
196        symlink(&real, &link).unwrap();
197
198        let write_tool = WriteTool::new();
199        let result = write_tool
200            .invoke(
201                json!({
202                    "file_path": link.join("test.txt"),
203                    "content": "hello"
204                }),
205                ToolCtx::none("t"),
206            )
207            .await;
208        assert!(matches!(result, Err(ToolError::Execution(msg)) if msg.contains("symlinked")));
209    }
210
211    #[tokio::test]
212    async fn write_includes_json_diagnostics_for_invalid_content() {
213        let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
214        let write_tool = WriteTool::new();
215
216        let out = write_tool
217            .invoke(
218                json!({
219                    "file_path": file.path(),
220                    "content": "{"
221                }),
222                ToolCtx::none("t"),
223            )
224            .await
225            .unwrap();
226        let ToolOutcome::Completed(result) = out else {
227            panic!("expected Completed")
228        };
229
230        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
231        assert_eq!(payload["diagnostics"]["format"], "json");
232        assert_eq!(payload["diagnostics"]["valid"], false);
233    }
234}