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