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 file_path: String,
14 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}