agent_sdk/primitive_tools/
write.rs1use crate::{Environment, PrimitiveToolName, Tool, ToolContext, ToolResult, ToolTier};
2use anyhow::{Context, Result};
3use serde::Deserialize;
4use serde_json::{Value, json};
5use std::sync::Arc;
6
7use super::PrimitiveToolContext;
8
9pub struct WriteTool<E: Environment> {
10 ctx: PrimitiveToolContext<E>,
11}
12
13impl<E: Environment> WriteTool<E> {
14 #[must_use]
15 pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
16 Self {
17 ctx: PrimitiveToolContext::new(environment, capabilities),
18 }
19 }
20}
21
22#[derive(Debug, Deserialize)]
23struct WriteInput {
24 #[serde(alias = "file_path")]
25 path: String,
26 content: String,
27}
28
29impl<E: Environment + 'static> Tool<()> for WriteTool<E> {
30 type Name = PrimitiveToolName;
31
32 fn name(&self) -> PrimitiveToolName {
33 PrimitiveToolName::Write
34 }
35
36 fn display_name(&self) -> &'static str {
37 "Write File"
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 let Err(reason) = self.ctx.capabilities.check_write(&path) {
72 return Ok(ToolResult::error(format!(
73 "Permission denied: cannot write to '{path}': {reason}"
74 )));
75 }
76
77 let exists = self
78 .ctx
79 .environment
80 .exists(&path)
81 .await
82 .context("Failed to check path existence")?;
83
84 if exists {
85 let is_dir = self
86 .ctx
87 .environment
88 .is_dir(&path)
89 .await
90 .context("Failed to check if path is directory")?;
91
92 if is_dir {
93 return Ok(ToolResult::error(format!(
94 "'{path}' is a directory, cannot write"
95 )));
96 }
97 }
98
99 self.ctx
100 .environment
101 .write_file(&path, &input.content)
102 .await
103 .context("Failed to write file")?;
104
105 let lines = input.content.lines().count();
106 let bytes = input.content.len();
107
108 Ok(ToolResult::success(format!(
109 "Successfully wrote {lines} lines ({bytes} bytes) to '{path}'"
110 )))
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::{AgentCapabilities, InMemoryFileSystem};
118
119 fn create_test_tool(
120 fs: Arc<InMemoryFileSystem>,
121 capabilities: AgentCapabilities,
122 ) -> WriteTool<InMemoryFileSystem> {
123 WriteTool::new(fs, capabilities)
124 }
125
126 fn tool_ctx() -> ToolContext<()> {
127 ToolContext::new(())
128 }
129
130 #[tokio::test]
131 async fn writes_new_file() -> anyhow::Result<()> {
132 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
133
134 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
135 let result = tool
136 .execute(
137 &tool_ctx(),
138 json!({"path": "/workspace/new.txt", "content": "Hello, World!"}),
139 )
140 .await?;
141
142 assert!(result.success);
143 assert!(result.output.contains("1 lines"));
144 assert!(result.output.contains("13 bytes"));
145
146 let content = fs.read_file("/workspace/new.txt").await?;
147 assert_eq!(content, "Hello, World!");
148 Ok(())
149 }
150
151 #[tokio::test]
152 async fn overwrites_existing_file() -> anyhow::Result<()> {
153 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
154 fs.write_file("existing.txt", "old content").await?;
155
156 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
157 let result = tool
158 .execute(
159 &tool_ctx(),
160 json!({"path": "/workspace/existing.txt", "content": "new content"}),
161 )
162 .await?;
163
164 assert!(result.success);
165 let content = fs.read_file("/workspace/existing.txt").await?;
166 assert_eq!(content, "new content");
167 Ok(())
168 }
169
170 #[tokio::test]
171 async fn writes_multiline_content() -> anyhow::Result<()> {
172 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
173 let content = "line 1\nline 2\nline 3\nline 4";
174
175 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
176 let result = tool
177 .execute(
178 &tool_ctx(),
179 json!({"path": "/workspace/multi.txt", "content": content}),
180 )
181 .await?;
182
183 assert!(result.success);
184 assert!(result.output.contains("4 lines"));
185 let read_content = fs.read_file("/workspace/multi.txt").await?;
186 assert_eq!(read_content, content);
187 Ok(())
188 }
189
190 #[tokio::test]
191 async fn errors_on_permission_denied() -> anyhow::Result<()> {
192 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
193 let tool = create_test_tool(fs, AgentCapabilities::read_only());
194
195 let result = tool
196 .execute(
197 &tool_ctx(),
198 json!({"path": "/workspace/test.txt", "content": "content"}),
199 )
200 .await?;
201
202 assert!(!result.success);
203 assert!(result.output.contains("Permission denied"));
204 Ok(())
205 }
206
207 #[tokio::test]
208 async fn errors_on_denied_paths() -> anyhow::Result<()> {
209 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
210 let caps = AgentCapabilities::full_access()
211 .with_denied_paths(vec!["/workspace/secrets/**".into()]);
212
213 let tool = create_test_tool(fs, caps);
214 let result = tool
215 .execute(
216 &tool_ctx(),
217 json!({"path": "/workspace/secrets/key.txt", "content": "secret"}),
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 errors_on_directory_target() -> anyhow::Result<()> {
228 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
229 fs.create_dir("/workspace/subdir").await?;
230
231 let tool = create_test_tool(fs, AgentCapabilities::full_access());
232 let result = tool
233 .execute(
234 &tool_ctx(),
235 json!({"path": "/workspace/subdir", "content": "content"}),
236 )
237 .await?;
238
239 assert!(!result.success);
240 assert!(result.output.contains("is a directory"));
241 Ok(())
242 }
243
244 #[tokio::test]
245 async fn writes_to_nested_directory() -> anyhow::Result<()> {
246 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
247
248 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
249 let result = tool
250 .execute(
251 &tool_ctx(),
252 json!({"path": "/workspace/deep/nested/file.txt", "content": "nested"}),
253 )
254 .await?;
255
256 assert!(result.success);
257 let content = fs.read_file("/workspace/deep/nested/file.txt").await?;
258 assert_eq!(content, "nested");
259 Ok(())
260 }
261
262 #[tokio::test]
263 async fn writes_empty_content() -> anyhow::Result<()> {
264 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
265
266 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
267 let result = tool
268 .execute(
269 &tool_ctx(),
270 json!({"path": "/workspace/empty.txt", "content": ""}),
271 )
272 .await?;
273
274 assert!(result.success);
275 assert!(result.output.contains("0 lines"));
276 assert!(result.output.contains("0 bytes"));
277 Ok(())
278 }
279
280 #[tokio::test]
281 async fn tool_metadata() {
282 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
283 let tool = create_test_tool(fs, AgentCapabilities::full_access());
284
285 assert_eq!(tool.name(), PrimitiveToolName::Write);
286 assert_eq!(tool.tier(), ToolTier::Confirm);
287
288 let schema = tool.input_schema();
289 assert!(schema["properties"].get("path").is_some());
290 assert!(schema["properties"].get("content").is_some());
291 }
292}