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 = AgentCapabilities::full_access()
263 .with_denied_paths(vec![])
264 .with_allowed_paths(vec!["/workspace/src/**".into()]);
265
266 let tool = create_test_tool(Arc::clone(&fs), caps.clone());
267
268 let result = tool
270 .execute(
271 &tool_ctx(),
272 json!({"path": "/workspace/src/main.rs", "content": "fn main() {}"}),
273 )
274 .await?;
275 assert!(result.success);
276
277 let tool = create_test_tool(fs, caps);
279 let result = tool
280 .execute(
281 &tool_ctx(),
282 json!({"path": "/workspace/config/settings.toml", "content": "key = value"}),
283 )
284 .await?;
285 assert!(!result.success);
286 assert!(result.output.contains("Permission denied"));
287 Ok(())
288 }
289
290 #[tokio::test]
295 async fn test_write_to_nested_directory() -> anyhow::Result<()> {
296 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
297
298 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
299 let result = tool
300 .execute(
301 &tool_ctx(),
302 json!({"path": "/workspace/deep/nested/dir/file.txt", "content": "nested content"}),
303 )
304 .await?;
305
306 assert!(result.success);
307
308 let content = fs.read_file("/workspace/deep/nested/dir/file.txt").await?;
310 assert_eq!(content, "nested content");
311 Ok(())
312 }
313
314 #[tokio::test]
315 async fn test_write_empty_content() -> anyhow::Result<()> {
316 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
317
318 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
319 let result = tool
320 .execute(
321 &tool_ctx(),
322 json!({"path": "/workspace/empty.txt", "content": ""}),
323 )
324 .await?;
325
326 assert!(result.success);
327 assert!(result.output.contains("0 lines"));
328 assert!(result.output.contains("0 bytes"));
329
330 let content = fs.read_file("/workspace/empty.txt").await?;
332 assert_eq!(content, "");
333 Ok(())
334 }
335
336 #[tokio::test]
337 async fn test_write_to_directory_path_returns_error() -> anyhow::Result<()> {
338 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
339 fs.create_dir("/workspace/subdir").await?;
340
341 let tool = create_test_tool(fs, AgentCapabilities::full_access());
342 let result = tool
343 .execute(
344 &tool_ctx(),
345 json!({"path": "/workspace/subdir", "content": "content"}),
346 )
347 .await?;
348
349 assert!(!result.success);
350 assert!(result.output.contains("is a directory"));
351 Ok(())
352 }
353
354 #[tokio::test]
355 async fn test_write_content_with_special_characters() -> anyhow::Result<()> {
356 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
357 let content = "特殊字符\néàü\n🎉emoji\ntab\there";
358
359 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
360 let result = tool
361 .execute(
362 &tool_ctx(),
363 json!({"path": "/workspace/special.txt", "content": content}),
364 )
365 .await?;
366
367 assert!(result.success);
368
369 let read_content = fs.read_file("/workspace/special.txt").await?;
371 assert_eq!(read_content, content);
372 Ok(())
373 }
374
375 #[tokio::test]
376 async fn test_write_tool_metadata() {
377 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
378 let tool = create_test_tool(fs, AgentCapabilities::full_access());
379
380 assert_eq!(tool.name(), PrimitiveToolName::Write);
381 assert_eq!(tool.tier(), ToolTier::Confirm);
382 assert!(tool.description().contains("Write"));
383
384 let schema = tool.input_schema();
385 assert!(schema.get("properties").is_some());
386 assert!(schema["properties"].get("path").is_some());
387 assert!(schema["properties"].get("content").is_some());
388 }
389
390 #[tokio::test]
391 async fn test_write_invalid_input_missing_path() -> anyhow::Result<()> {
392 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
393 let tool = create_test_tool(fs, AgentCapabilities::full_access());
394
395 let result = tool
397 .execute(&tool_ctx(), json!({"content": "some content"}))
398 .await;
399
400 assert!(result.is_err());
401 Ok(())
402 }
403
404 #[tokio::test]
405 async fn test_write_invalid_input_missing_content() -> anyhow::Result<()> {
406 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
407 let tool = create_test_tool(fs, AgentCapabilities::full_access());
408
409 let result = tool
411 .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
412 .await;
413
414 assert!(result.is_err());
415 Ok(())
416 }
417
418 #[tokio::test]
419 async fn test_write_large_file() -> anyhow::Result<()> {
420 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
421
422 let content: String = (1..=1000)
424 .map(|i| format!("line {i}"))
425 .collect::<Vec<_>>()
426 .join("\n");
427
428 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
429 let result = tool
430 .execute(
431 &tool_ctx(),
432 json!({"path": "/workspace/large.txt", "content": content}),
433 )
434 .await?;
435
436 assert!(result.success);
437 assert!(result.output.contains("1000 lines"));
438
439 let read_content = fs.read_file("/workspace/large.txt").await?;
441 assert_eq!(read_content, content);
442 Ok(())
443 }
444}