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