Skip to main content

agent_sdk/primitive_tools/
write.rs

1use 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}