agent_sdk/primitive_tools/
write.rs1use crate::reminders::{append_reminder, builtin};
2use crate::{Environment, Tool, ToolContext, ToolResult, ToolTier};
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use serde::Deserialize;
6use serde_json::{Value, json};
7use std::sync::Arc;
8
9use super::PrimitiveToolContext;
10
11pub struct WriteTool<E: Environment> {
13 ctx: PrimitiveToolContext<E>,
14}
15
16impl<E: Environment> WriteTool<E> {
17 #[must_use]
18 pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
19 Self {
20 ctx: PrimitiveToolContext::new(environment, capabilities),
21 }
22 }
23}
24
25#[derive(Debug, Deserialize)]
26struct WriteInput {
27 #[serde(alias = "file_path")]
29 path: String,
30 content: String,
32}
33
34#[async_trait]
35impl<E: Environment + 'static> Tool<()> for WriteTool<E> {
36 fn name(&self) -> &'static str {
37 "write"
38 }
39
40 fn description(&self) -> &'static str {
41 "Write content to a file. Creates the file if it doesn't exist, overwrites if it does."
42 }
43
44 fn tier(&self) -> ToolTier {
45 ToolTier::Confirm
46 }
47
48 fn input_schema(&self) -> Value {
49 json!({
50 "type": "object",
51 "properties": {
52 "path": {
53 "type": "string",
54 "description": "Path to the file to write"
55 },
56 "content": {
57 "type": "string",
58 "description": "Content to write to the file"
59 }
60 },
61 "required": ["path", "content"]
62 })
63 }
64
65 async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
66 let input: WriteInput =
67 serde_json::from_value(input).context("Invalid input for write tool")?;
68
69 let path = self.ctx.environment.resolve_path(&input.path);
70
71 if !self.ctx.capabilities.can_write(&path) {
73 return Ok(ToolResult::error(format!(
74 "Permission denied: cannot write to '{path}'"
75 )));
76 }
77
78 let exists = self
80 .ctx
81 .environment
82 .exists(&path)
83 .await
84 .context("Failed to check path existence")?;
85
86 if exists {
87 let is_dir = self
88 .ctx
89 .environment
90 .is_dir(&path)
91 .await
92 .context("Failed to check if path is directory")?;
93
94 if is_dir {
95 return Ok(ToolResult::error(format!(
96 "'{path}' is a directory, cannot write"
97 )));
98 }
99 }
100
101 self.ctx
103 .environment
104 .write_file(&path, &input.content)
105 .await
106 .context("Failed to write file")?;
107
108 let lines = input.content.lines().count();
109 let bytes = input.content.len();
110
111 let mut result = ToolResult::success(format!(
112 "Successfully wrote {lines} lines ({bytes} bytes) to '{path}'"
113 ));
114
115 append_reminder(&mut result, builtin::WRITE_VERIFICATION_REMINDER);
117
118 Ok(result)
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use crate::{AgentCapabilities, InMemoryFileSystem};
126
127 fn create_test_tool(
128 fs: Arc<InMemoryFileSystem>,
129 capabilities: AgentCapabilities,
130 ) -> WriteTool<InMemoryFileSystem> {
131 WriteTool::new(fs, capabilities)
132 }
133
134 fn tool_ctx() -> ToolContext<()> {
135 ToolContext::new(())
136 }
137
138 #[tokio::test]
143 async fn test_write_new_file() -> anyhow::Result<()> {
144 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
145
146 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
147 let result = tool
148 .execute(
149 &tool_ctx(),
150 json!({"path": "/workspace/new_file.txt", "content": "Hello, World!"}),
151 )
152 .await?;
153
154 assert!(result.success);
155 assert!(result.output.contains("Successfully wrote"));
156 assert!(result.output.contains("1 lines"));
157 assert!(result.output.contains("13 bytes"));
158
159 let content = fs.read_file("/workspace/new_file.txt").await?;
161 assert_eq!(content, "Hello, World!");
162 Ok(())
163 }
164
165 #[tokio::test]
166 async fn test_write_overwrite_existing_file() -> anyhow::Result<()> {
167 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
168 fs.write_file("existing.txt", "old content").await?;
169
170 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
171 let result = tool
172 .execute(
173 &tool_ctx(),
174 json!({"path": "/workspace/existing.txt", "content": "new content"}),
175 )
176 .await?;
177
178 assert!(result.success);
179
180 let content = fs.read_file("/workspace/existing.txt").await?;
182 assert_eq!(content, "new content");
183 Ok(())
184 }
185
186 #[tokio::test]
187 async fn test_write_multiline_content() -> anyhow::Result<()> {
188 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
189 let content = "line 1\nline 2\nline 3\nline 4";
190
191 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
192 let result = tool
193 .execute(
194 &tool_ctx(),
195 json!({"path": "/workspace/multi.txt", "content": content}),
196 )
197 .await?;
198
199 assert!(result.success);
200 assert!(result.output.contains("4 lines"));
201
202 let read_content = fs.read_file("/workspace/multi.txt").await?;
204 assert_eq!(read_content, content);
205 Ok(())
206 }
207
208 #[tokio::test]
213 async fn test_write_permission_denied_no_write_capability() -> anyhow::Result<()> {
214 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
215
216 let caps = AgentCapabilities::read_only();
218
219 let tool = create_test_tool(fs, caps);
220 let result = tool
221 .execute(
222 &tool_ctx(),
223 json!({"path": "/workspace/test.txt", "content": "content"}),
224 )
225 .await?;
226
227 assert!(!result.success);
228 assert!(result.output.contains("Permission denied"));
229 Ok(())
230 }
231
232 #[tokio::test]
233 async fn test_write_permission_denied_via_denied_paths() -> anyhow::Result<()> {
234 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
235
236 let caps = AgentCapabilities::full_access()
238 .with_denied_paths(vec!["/workspace/secrets/**".into()]);
239
240 let tool = create_test_tool(fs, caps);
241 let result = tool
242 .execute(
243 &tool_ctx(),
244 json!({"path": "/workspace/secrets/key.txt", "content": "secret"}),
245 )
246 .await?;
247
248 assert!(!result.success);
249 assert!(result.output.contains("Permission denied"));
250 Ok(())
251 }
252
253 #[tokio::test]
254 async fn test_write_allowed_path_restriction() -> anyhow::Result<()> {
255 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
256
257 let caps = AgentCapabilities::full_access()
259 .with_denied_paths(vec![])
260 .with_allowed_paths(vec!["/workspace/src/**".into()]);
261
262 let tool = create_test_tool(Arc::clone(&fs), caps.clone());
263
264 let result = tool
266 .execute(
267 &tool_ctx(),
268 json!({"path": "/workspace/src/main.rs", "content": "fn main() {}"}),
269 )
270 .await?;
271 assert!(result.success);
272
273 let tool = create_test_tool(fs, caps);
275 let result = tool
276 .execute(
277 &tool_ctx(),
278 json!({"path": "/workspace/config/settings.toml", "content": "key = value"}),
279 )
280 .await?;
281 assert!(!result.success);
282 assert!(result.output.contains("Permission denied"));
283 Ok(())
284 }
285
286 #[tokio::test]
291 async fn test_write_to_nested_directory() -> anyhow::Result<()> {
292 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
293
294 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
295 let result = tool
296 .execute(
297 &tool_ctx(),
298 json!({"path": "/workspace/deep/nested/dir/file.txt", "content": "nested content"}),
299 )
300 .await?;
301
302 assert!(result.success);
303
304 let content = fs.read_file("/workspace/deep/nested/dir/file.txt").await?;
306 assert_eq!(content, "nested content");
307 Ok(())
308 }
309
310 #[tokio::test]
311 async fn test_write_empty_content() -> anyhow::Result<()> {
312 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
313
314 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
315 let result = tool
316 .execute(
317 &tool_ctx(),
318 json!({"path": "/workspace/empty.txt", "content": ""}),
319 )
320 .await?;
321
322 assert!(result.success);
323 assert!(result.output.contains("0 lines"));
324 assert!(result.output.contains("0 bytes"));
325
326 let content = fs.read_file("/workspace/empty.txt").await?;
328 assert_eq!(content, "");
329 Ok(())
330 }
331
332 #[tokio::test]
333 async fn test_write_to_directory_path_returns_error() -> anyhow::Result<()> {
334 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
335 fs.create_dir("/workspace/subdir").await?;
336
337 let tool = create_test_tool(fs, AgentCapabilities::full_access());
338 let result = tool
339 .execute(
340 &tool_ctx(),
341 json!({"path": "/workspace/subdir", "content": "content"}),
342 )
343 .await?;
344
345 assert!(!result.success);
346 assert!(result.output.contains("is a directory"));
347 Ok(())
348 }
349
350 #[tokio::test]
351 async fn test_write_content_with_special_characters() -> anyhow::Result<()> {
352 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
353 let content = "特殊字符\néàü\n🎉emoji\ntab\there";
354
355 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
356 let result = tool
357 .execute(
358 &tool_ctx(),
359 json!({"path": "/workspace/special.txt", "content": content}),
360 )
361 .await?;
362
363 assert!(result.success);
364
365 let read_content = fs.read_file("/workspace/special.txt").await?;
367 assert_eq!(read_content, content);
368 Ok(())
369 }
370
371 #[tokio::test]
372 async fn test_write_tool_metadata() {
373 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
374 let tool = create_test_tool(fs, AgentCapabilities::full_access());
375
376 assert_eq!(tool.name(), "write");
377 assert_eq!(tool.tier(), ToolTier::Confirm);
378 assert!(tool.description().contains("Write"));
379
380 let schema = tool.input_schema();
381 assert!(schema.get("properties").is_some());
382 assert!(schema["properties"].get("path").is_some());
383 assert!(schema["properties"].get("content").is_some());
384 }
385
386 #[tokio::test]
387 async fn test_write_invalid_input_missing_path() -> anyhow::Result<()> {
388 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
389 let tool = create_test_tool(fs, AgentCapabilities::full_access());
390
391 let result = tool
393 .execute(&tool_ctx(), json!({"content": "some content"}))
394 .await;
395
396 assert!(result.is_err());
397 Ok(())
398 }
399
400 #[tokio::test]
401 async fn test_write_invalid_input_missing_content() -> anyhow::Result<()> {
402 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
403 let tool = create_test_tool(fs, AgentCapabilities::full_access());
404
405 let result = tool
407 .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
408 .await;
409
410 assert!(result.is_err());
411 Ok(())
412 }
413
414 #[tokio::test]
415 async fn test_write_large_file() -> anyhow::Result<()> {
416 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
417
418 let content: String = (1..=1000)
420 .map(|i| format!("line {i}"))
421 .collect::<Vec<_>>()
422 .join("\n");
423
424 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
425 let result = tool
426 .execute(
427 &tool_ctx(),
428 json!({"path": "/workspace/large.txt", "content": content}),
429 )
430 .await?;
431
432 assert!(result.success);
433 assert!(result.output.contains("1000 lines"));
434
435 let read_content = fs.read_file("/workspace/large.txt").await?;
437 assert_eq!(read_content, content);
438 Ok(())
439 }
440}