bamboo_tools/tools/
write.rs1use 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}