bamboo_tools/tools/
write.rs1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolExecutionContext, 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 execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
59 self.execute_with_context(args, ToolExecutionContext::none("Write"))
60 .await
61 }
62
63 async fn execute_with_context(
64 &self,
65 args: serde_json::Value,
66 ctx: ToolExecutionContext<'_>,
67 ) -> Result<ToolResult, ToolError> {
68 let parsed: WriteArgs = serde_json::from_value(args)
69 .map_err(|e| ToolError::InvalidArguments(format!("Invalid Write args: {}", e)))?;
70
71 let file_path = parsed.file_path.trim();
72 let path = Path::new(file_path);
73
74 if !path.is_absolute() {
75 return Err(ToolError::InvalidArguments(
76 "file_path must be an absolute path".to_string(),
77 ));
78 }
79
80 if path.exists() {
81 if let Some(session_id) = ctx.session_id {
82 match read_tracker::read_state(session_id, file_path).await {
83 ReadState::Unread => {
84 return Err(ToolError::Execution(
85 "Write requires reading the target file first via Read".to_string(),
86 ));
87 }
88 ReadState::Stale => {
89 return Err(ToolError::Execution(
90 "Target file changed after last Read; call Read again before Write"
91 .to_string(),
92 ));
93 }
94 ReadState::Fresh => {}
95 }
96 }
97 }
98
99 let previous_bytes = file_change::read_existing_bytes(path).await?;
100 let checkpoint = file_change::create_checkpoint(path, previous_bytes.as_deref()).await?;
101 let next_content = parsed.content;
102
103 file_change::atomic_write_text(path, &next_content).await?;
104
105 let previous_text = file_change::bytes_to_lossy_text(previous_bytes.as_deref());
106 let mut payload = file_change::build_file_change_payload_value(
107 "Write",
108 path,
109 format!("Wrote file: {}", file_path),
110 checkpoint,
111 &previous_text,
112 &next_content,
113 );
114 content_diagnostics::attach_file_diagnostics(&mut payload, path, &next_content);
115
116 Ok(ToolResult {
117 success: true,
118 result: payload.to_string(),
119 display_preference: Some("Default".to_string()),
120 images: Vec::new(),
121 })
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use crate::tools::ReadTool;
129 use serde_json::json;
130
131 fn ctx<'a>(session_id: &'a str) -> ToolExecutionContext<'a> {
132 ToolExecutionContext {
133 session_id: Some(session_id),
134 tool_call_id: "call_1",
135 event_tx: None,
136 available_tool_schemas: None,
137 bypass_permissions: false,
138 can_async_resume: false,
139 }
140 }
141
142 #[tokio::test]
143 async fn write_requires_fresh_read_for_existing_files() {
144 let file = tempfile::NamedTempFile::new().unwrap();
145 tokio::fs::write(file.path(), "v1").await.unwrap();
146 let write_tool = WriteTool::new();
147 let read_tool = ReadTool::new();
148
149 let denied = write_tool
150 .execute_with_context(
151 json!({"file_path": file.path(), "content": "v2"}),
152 ctx("session_a"),
153 )
154 .await;
155 assert!(matches!(denied, Err(ToolError::Execution(_))));
156
157 let _ = read_tool
158 .execute_with_context(json!({"file_path": file.path()}), ctx("session_a"))
159 .await
160 .unwrap();
161
162 tokio::fs::write(file.path(), "external change")
163 .await
164 .unwrap();
165
166 let stale = write_tool
167 .execute_with_context(
168 json!({"file_path": file.path(), "content": "v3"}),
169 ctx("session_a"),
170 )
171 .await;
172 assert!(matches!(stale, Err(ToolError::Execution(msg)) if msg.contains("changed")));
173
174 let _ = read_tool
175 .execute_with_context(json!({"file_path": file.path()}), ctx("session_a"))
176 .await
177 .unwrap();
178 let ok = write_tool
179 .execute_with_context(
180 json!({"file_path": file.path(), "content": "final"}),
181 ctx("session_a"),
182 )
183 .await
184 .unwrap();
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 .execute(json!({
201 "file_path": link.join("test.txt"),
202 "content": "hello"
203 }))
204 .await;
205 assert!(matches!(result, Err(ToolError::Execution(msg)) if msg.contains("symlinked")));
206 }
207
208 #[tokio::test]
209 async fn write_includes_json_diagnostics_for_invalid_content() {
210 let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
211 let write_tool = WriteTool::new();
212
213 let result = write_tool
214 .execute(json!({
215 "file_path": file.path(),
216 "content": "{"
217 }))
218 .await
219 .unwrap();
220
221 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
222 assert_eq!(payload["diagnostics"]["format"], "json");
223 assert_eq!(payload["diagnostics"]["valid"], false);
224 }
225}