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 std::path::Path;
5
6use super::read_tracker::{BaselineAdvance, ReadState};
7use super::{content_diagnostics, file_change, parameter_schema, read_tracker};
8
9#[derive(Debug, Deserialize, schemars::JsonSchema)]
10#[schemars(deny_unknown_fields)]
11struct WriteArgs {
12    /// The absolute path to the file to write
13    file_path: String,
14    /// The content to write to the file
15    content: String,
16}
17
18pub struct WriteTool;
19
20impl WriteTool {
21    pub fn new() -> Self {
22        Self
23    }
24}
25
26impl Default for WriteTool {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32#[async_trait]
33impl Tool for WriteTool {
34    fn name(&self) -> &str {
35        "Write"
36    }
37
38    fn description(&self) -> &str {
39        "Write a local file (create or replace full content). IMPORTANT: for existing files, call Read first in this session or Write will fail."
40    }
41
42    fn parameters_schema(&self) -> serde_json::Value {
43        parameter_schema::for_arguments::<WriteArgs>()
44    }
45
46    async fn invoke(
47        &self,
48        args: serde_json::Value,
49        ctx: ToolCtx,
50    ) -> Result<ToolOutcome, ToolError> {
51        let parsed: WriteArgs = serde_json::from_value(args)
52            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Write args: {}", e)))?;
53
54        let file_path = parsed.file_path.trim();
55        let path = Path::new(file_path);
56
57        if !path.is_absolute() {
58            return Err(ToolError::InvalidArguments(
59                "file_path must be an absolute path".to_string(),
60            ));
61        }
62
63        let session_id = ctx.session_id().map(str::to_owned);
64        let target_existed = tokio::fs::try_exists(path)
65            .await
66            .map_err(|e| ToolError::Execution(format!("Failed to inspect target file: {}", e)))?;
67        let validated_read = if target_existed {
68            if let Some(session_id) = session_id.as_deref() {
69                match read_tracker::read_if_fresh(session_id, file_path).await {
70                    Ok(validated) => Some(validated),
71                    Err(ReadState::Unread) => {
72                        return Err(ToolError::Execution(
73                            "Write requires reading the target file first via Read".to_string(),
74                        ));
75                    }
76                    Err(ReadState::Stale) => {
77                        return Err(ToolError::Execution(
78                            "Target file changed after last Read; call Read again before Write"
79                                .to_string(),
80                        ));
81                    }
82                    Err(ReadState::Fresh) => {
83                        unreachable!("Fresh is returned as a validated read")
84                    }
85                }
86            } else {
87                None
88            }
89        } else {
90            None
91        };
92        let new_file_slot =
93            if let (Some(session_id), false) = (session_id.as_deref(), target_existed) {
94                Some(read_tracker::capture_write_slot(session_id, file_path).await)
95            } else {
96                None
97            };
98
99        let previous_bytes = if let Some(validated) = validated_read.as_ref() {
100            Some(validated.bytes().to_vec())
101        } else if target_existed {
102            file_change::read_existing_bytes(path).await?
103        } else {
104            None
105        };
106        let checkpoint = file_change::create_checkpoint(path, previous_bytes.as_deref()).await?;
107        let next_content = parsed.content;
108
109        let write_expectation = if let Some(validated) = validated_read.as_ref() {
110            file_change::AtomicWriteExpectation::Exact(validated.bytes())
111        } else if session_id.is_some() && !target_existed {
112            file_change::AtomicWriteExpectation::Missing
113        } else {
114            file_change::AtomicWriteExpectation::Unchecked
115        };
116        file_change::atomic_write_text_with_expectation(path, &next_content, write_expectation)
117            .await?;
118
119        let mutation_slot = validated_read
120            .as_ref()
121            .map(|validated| validated.slot())
122            .or(new_file_slot.as_ref());
123        if let Some(slot) = mutation_slot {
124            if read_tracker::advance_after_verified_write(file_path, slot, next_content.as_bytes())
125                .await
126                == BaselineAdvance::Conflict
127            {
128                return Err(ToolError::Execution(
129                    "Write committed, but the target changed before it could be verified; call Read again"
130                        .to_string(),
131                ));
132            }
133        }
134
135        let previous_text = file_change::bytes_to_lossy_text(previous_bytes.as_deref());
136        let mut payload = file_change::build_file_change_payload_value(
137            "Write",
138            path,
139            format!("Wrote file: {}", file_path),
140            checkpoint,
141            &previous_text,
142            &next_content,
143        );
144        content_diagnostics::attach_file_diagnostics(&mut payload, path, &next_content);
145
146        Ok(ToolOutcome::Completed(ToolResult {
147            success: true,
148            result: payload.to_string(),
149            display_preference: Some("Default".to_string()),
150            images: Vec::new(),
151        }))
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::tools::ReadTool;
159    use serde_json::json;
160
161    #[test]
162    fn schema_derivation_does_not_change_extra_argument_compatibility() {
163        let args: WriteArgs = serde_json::from_value(json!({
164            "file_path": "/tmp/example.txt",
165            "content": "example",
166            "legacy_extra": true
167        }))
168        .unwrap();
169        assert_eq!(args.content, "example");
170    }
171
172    fn ctx(session_id: &str) -> ToolCtx {
173        ToolCtx {
174            executing_caller: None,
175            executing_supervisor: None,
176            session_id: Some(std::sync::Arc::from(session_id)),
177            tool_call_id: std::sync::Arc::from("call_1"),
178            event_tx: None,
179            available_tool_schemas: std::sync::Arc::from(Vec::new()),
180            bypass_permissions: false,
181            auto_approve_permissions: false,
182            plan_read_only: false,
183            can_async_resume: false,
184            async_completion_sink: None,
185            bash_completion_sink: None,
186        }
187    }
188
189    #[tokio::test]
190    async fn write_requires_fresh_read_for_existing_files() {
191        let file = tempfile::NamedTempFile::new().unwrap();
192        tokio::fs::write(file.path(), "v1").await.unwrap();
193        let write_tool = WriteTool::new();
194        let read_tool = ReadTool::new();
195
196        let denied = write_tool
197            .invoke(
198                json!({"file_path": file.path(), "content": "v2"}),
199                ctx("session_a"),
200            )
201            .await;
202        assert!(matches!(denied, Err(ToolError::Execution(_))));
203
204        let _ = read_tool
205            .invoke(json!({"file_path": file.path()}), ctx("session_a"))
206            .await
207            .unwrap();
208
209        tokio::fs::write(file.path(), "external change")
210            .await
211            .unwrap();
212
213        let stale = write_tool
214            .invoke(
215                json!({"file_path": file.path(), "content": "v3"}),
216                ctx("session_a"),
217            )
218            .await;
219        assert!(matches!(stale, Err(ToolError::Execution(msg)) if msg.contains("changed")));
220
221        let _ = read_tool
222            .invoke(json!({"file_path": file.path()}), ctx("session_a"))
223            .await
224            .unwrap();
225        let out = write_tool
226            .invoke(
227                json!({"file_path": file.path(), "content": "final"}),
228                ctx("session_a"),
229            )
230            .await
231            .unwrap();
232        let ToolOutcome::Completed(ok) = out else {
233            panic!("expected Completed")
234        };
235        assert!(ok.success);
236    }
237
238    #[tokio::test]
239    async fn read_write_write_succeeds_without_an_external_change() {
240        let file = tempfile::NamedTempFile::new().unwrap();
241        tokio::fs::write(file.path(), "v1").await.unwrap();
242        let session = format!("write-twice-{}", uuid::Uuid::new_v4());
243        let read_tool = ReadTool::new();
244        let write_tool = WriteTool::new();
245
246        read_tool
247            .invoke(json!({"file_path": file.path()}), ctx(&session))
248            .await
249            .unwrap();
250        write_tool
251            .invoke(
252                json!({"file_path": file.path(), "content": "v2"}),
253                ctx(&session),
254            )
255            .await
256            .unwrap();
257        write_tool
258            .invoke(
259                json!({"file_path": file.path(), "content": "v3"}),
260                ctx(&session),
261            )
262            .await
263            .unwrap();
264
265        assert_eq!(tokio::fs::read_to_string(file.path()).await.unwrap(), "v3");
266    }
267
268    #[tokio::test]
269    async fn write_rejects_external_change_after_a_successful_write() {
270        let file = tempfile::NamedTempFile::new().unwrap();
271        tokio::fs::write(file.path(), "aa").await.unwrap();
272        let session = format!("write-external-{}", uuid::Uuid::new_v4());
273        let read_tool = ReadTool::new();
274        let write_tool = WriteTool::new();
275
276        read_tool
277            .invoke(json!({"file_path": file.path()}), ctx(&session))
278            .await
279            .unwrap();
280        write_tool
281            .invoke(
282                json!({"file_path": file.path(), "content": "bb"}),
283                ctx(&session),
284            )
285            .await
286            .unwrap();
287
288        tokio::fs::write(file.path(), "cc").await.unwrap();
289        let stale = write_tool
290            .invoke(
291                json!({"file_path": file.path(), "content": "dd"}),
292                ctx(&session),
293            )
294            .await;
295
296        assert!(matches!(stale, Err(ToolError::Execution(message)) if message.contains("changed")));
297        assert_eq!(tokio::fs::read_to_string(file.path()).await.unwrap(), "cc");
298    }
299
300    #[tokio::test]
301    async fn stale_write_failure_does_not_advance_the_baseline() {
302        let file = tempfile::NamedTempFile::new().unwrap();
303        tokio::fs::write(file.path(), "v1").await.unwrap();
304        let session = format!("write-stale-failure-{}", uuid::Uuid::new_v4());
305        let read_tool = ReadTool::new();
306        let write_tool = WriteTool::new();
307
308        read_tool
309            .invoke(json!({"file_path": file.path()}), ctx(&session))
310            .await
311            .unwrap();
312        tokio::fs::write(file.path(), "external").await.unwrap();
313
314        for intended in ["first-attempt", "second-attempt"] {
315            let stale = write_tool
316                .invoke(
317                    json!({"file_path": file.path(), "content": intended}),
318                    ctx(&session),
319                )
320                .await;
321            assert!(
322                matches!(stale, Err(ToolError::Execution(message)) if message.contains("changed"))
323            );
324        }
325        assert_eq!(
326            tokio::fs::read_to_string(file.path()).await.unwrap(),
327            "external"
328        );
329    }
330
331    #[tokio::test]
332    async fn consecutive_writes_to_a_new_file_use_the_verified_first_write() {
333        let dir = tempfile::tempdir().unwrap();
334        let path = dir.path().join("new.txt");
335        let session = format!("write-new-twice-{}", uuid::Uuid::new_v4());
336        let write_tool = WriteTool::new();
337
338        write_tool
339            .invoke(
340                json!({"file_path": path, "content": "first"}),
341                ctx(&session),
342            )
343            .await
344            .unwrap();
345        write_tool
346            .invoke(
347                json!({"file_path": path, "content": "second"}),
348                ctx(&session),
349            )
350            .await
351            .unwrap();
352
353        assert_eq!(tokio::fs::read_to_string(path).await.unwrap(), "second");
354    }
355
356    #[tokio::test]
357    async fn concurrent_read_of_new_file_is_idempotent_for_first_write() {
358        let dir = tempfile::tempdir().unwrap();
359        let path = dir.path().join("new-concurrent.txt");
360        let path_str = path.to_string_lossy().into_owned();
361        let session = format!("write-new-concurrent-{}", uuid::Uuid::new_v4());
362        let (advance_reached, resume_advance) =
363            read_tracker::pause_next_advance_for_test(&session, &path_str).await;
364
365        let writer_path = path.clone();
366        let writer_session = session.clone();
367        let writer = tokio::spawn(async move {
368            WriteTool::new()
369                .invoke(
370                    json!({"file_path": writer_path, "content": "first"}),
371                    ctx(&writer_session),
372                )
373                .await
374        });
375
376        tokio::time::timeout(
377            std::time::Duration::from_secs(5),
378            advance_reached.notified(),
379        )
380        .await
381        .expect("Write did not reach post-write baseline advancement");
382        ReadTool::new()
383            .invoke(json!({"file_path": path}), ctx(&session))
384            .await
385            .unwrap();
386        resume_advance.notify_one();
387
388        let first = tokio::time::timeout(std::time::Duration::from_secs(5), writer)
389            .await
390            .expect("Write did not resume")
391            .unwrap()
392            .unwrap();
393        assert!(matches!(first, ToolOutcome::Completed(result) if result.success));
394
395        WriteTool::new()
396            .invoke(
397                json!({"file_path": path, "content": "second"}),
398                ctx(&session),
399            )
400            .await
401            .unwrap();
402        assert_eq!(tokio::fs::read_to_string(path).await.unwrap(), "second");
403    }
404
405    #[tokio::test]
406    async fn committed_postverify_conflict_is_clear_and_leaves_baseline_stale() {
407        let file = tempfile::NamedTempFile::new().unwrap();
408        tokio::fs::write(file.path(), "before").await.unwrap();
409        let path = file.path().to_path_buf();
410        let path_str = path.to_string_lossy().into_owned();
411        let session = format!("write-postverify-conflict-{}", uuid::Uuid::new_v4());
412
413        ReadTool::new()
414            .invoke(json!({"file_path": path}), ctx(&session))
415            .await
416            .unwrap();
417        let (advance_reached, resume_advance) =
418            read_tracker::pause_next_advance_for_test(&session, &path_str).await;
419
420        let writer_path = path.clone();
421        let writer_session = session.clone();
422        let writer = tokio::spawn(async move {
423            WriteTool::new()
424                .invoke(
425                    json!({"file_path": writer_path, "content": "intended"}),
426                    ctx(&writer_session),
427                )
428                .await
429        });
430
431        tokio::time::timeout(
432            std::time::Duration::from_secs(5),
433            advance_reached.notified(),
434        )
435        .await
436        .expect("Write did not reach post-write baseline advancement");
437        tokio::fs::write(&path, "other").await.unwrap();
438        ReadTool::new()
439            .invoke(json!({"file_path": path}), ctx(&session))
440            .await
441            .unwrap();
442        tokio::fs::write(&path, "intended").await.unwrap();
443        resume_advance.notify_one();
444
445        let result = tokio::time::timeout(std::time::Duration::from_secs(5), writer)
446            .await
447            .expect("Write did not resume")
448            .unwrap();
449        assert!(
450            matches!(result, Err(ToolError::Execution(message)) if message.contains("Write committed"))
451        );
452        assert_eq!(
453            read_tracker::read_state(&session, &path_str).await,
454            ReadState::Stale
455        );
456        assert_eq!(tokio::fs::read_to_string(path).await.unwrap(), "intended");
457    }
458
459    #[cfg(unix)]
460    #[tokio::test]
461    async fn write_rejects_symlinked_path_components() {
462        use std::os::unix::fs::symlink;
463        let dir = tempfile::tempdir().unwrap();
464        let real = dir.path().join("real");
465        let link = dir.path().join("link");
466        tokio::fs::create_dir_all(&real).await.unwrap();
467        symlink(&real, &link).unwrap();
468
469        let write_tool = WriteTool::new();
470        let result = write_tool
471            .invoke(
472                json!({
473                    "file_path": link.join("test.txt"),
474                    "content": "hello"
475                }),
476                ToolCtx::none("t"),
477            )
478            .await;
479        assert!(matches!(result, Err(ToolError::Execution(msg)) if msg.contains("symlinked")));
480    }
481
482    #[tokio::test]
483    async fn write_includes_json_diagnostics_for_invalid_content() {
484        let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
485        let write_tool = WriteTool::new();
486
487        let out = write_tool
488            .invoke(
489                json!({
490                    "file_path": file.path(),
491                    "content": "{"
492                }),
493                ToolCtx::none("t"),
494            )
495            .await
496            .unwrap();
497        let ToolOutcome::Completed(result) = out else {
498            panic!("expected Completed")
499        };
500
501        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
502        assert_eq!(payload["diagnostics"]["format"], "json");
503        assert_eq!(payload["diagnostics"]["valid"], false);
504    }
505}