Skip to main content

agent_engine/tools/
write.rs

1use super::{resolve_path_in, Tool, ToolContext};
2use crate::{Result, RuntimeError};
3use serde_json::{json, Value};
4
5pub struct WriteTool;
6
7#[async_trait::async_trait]
8impl Tool for WriteTool {
9    fn effect(&self) -> crate::tools::catalog::ToolEffect {
10        crate::tools::catalog::ToolEffect::IdempotentWrite
11    }
12
13    fn concurrency_key(
14        &self,
15        validated_input: &serde_json::Value,
16    ) -> Option<super::ConcurrencyKey> {
17        self.concurrency_key_in(validated_input, None)
18    }
19
20    fn concurrency_key_in(
21        &self,
22        validated_input: &serde_json::Value,
23        cwd: Option<&std::path::Path>,
24    ) -> Option<super::ConcurrencyKey> {
25        Some(
26            match super::util::canonical_path_key_in(validated_input["path"].as_str()?, cwd) {
27                Some(key) => super::ConcurrencyKey::Key(key),
28                None => super::ConcurrencyKey::Serialize,
29            },
30        )
31    }
32
33    fn origin(&self) -> crate::tools::ToolOrigin {
34        crate::tools::ToolOrigin::Builtin
35    }
36
37    fn name(&self) -> &str {
38        "write"
39    }
40
41    fn description(&self) -> &str {
42        "Create or overwrite a file with the given content. Creates parent directories if needed. Use this for creating new files or completely rewriting existing ones."
43    }
44
45    fn parameters(&self) -> Value {
46        json!({
47            "type": "object",
48            "properties": {
49                "path": {
50                    "type": "string",
51                    "description": "Path to the file to write"
52                },
53                "content": {
54                    "type": "string",
55                    "description": "Content to write to the file"
56                }
57            },
58            "required": ["path", "content"]
59        })
60    }
61
62    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
63        let raw_path = params["path"]
64            .as_str()
65            .ok_or_else(|| RuntimeError::Tool("Missing path parameter".to_string()))?;
66        let content = params["content"]
67            .as_str()
68            .ok_or_else(|| RuntimeError::Tool("Missing content parameter".to_string()))?;
69        if let Some(orchestration) = &ctx.capabilities.orchestration {
70            if matches!(
71                orchestration.check_foreground_write(raw_path),
72                agent_core::orchestration::ScopeDecision::ReconciliationRequired { .. }
73            ) {
74                return Err(RuntimeError::Tool(
75                    "foreground write overlaps an active worker scope".into(),
76                ));
77            }
78        }
79
80        let path = resolve_path_in(raw_path, ctx.capabilities.cwd.as_deref());
81
82        if let Some(parent) = path.parent() {
83            if !parent.as_os_str().is_empty() {
84                tokio::fs::create_dir_all(parent).await.map_err(|e| {
85                    RuntimeError::Tool(format!("Failed to create directories: {}", e))
86                })?;
87            }
88        }
89
90        // Preserve permissions if overwriting an existing file
91        let original_perms = tokio::fs::metadata(&path)
92            .await
93            .map(|m| m.permissions())
94            .ok();
95
96        let tmp_path = super::util::unique_tmp_path(&path);
97        tokio::fs::write(&tmp_path, content)
98            .await
99            .map_err(|e| RuntimeError::Tool(format!("Failed to write file: {}", e)))?;
100
101        if let Some(perms) = original_perms {
102            let _ = tokio::fs::set_permissions(&tmp_path, perms).await;
103        }
104
105        tokio::fs::rename(&tmp_path, &path).await.map_err(|e| {
106            let tmp = tmp_path.clone();
107            tokio::spawn(async move {
108                let _ = tokio::fs::remove_file(tmp).await;
109            });
110            RuntimeError::Tool(format!("Failed to finalize write: {}", e))
111        })?;
112
113        let line_count = content.lines().count();
114        Ok(format!(
115            "Wrote {} lines ({} bytes) to {}",
116            line_count,
117            content.len(),
118            path.display()
119        ))
120    }
121}
122#[cfg(test)]
123mod tests {
124    use super::super::test_helpers::create_tool_context;
125    use super::*;
126    use crate::tools::Tool;
127    use serde_json::json;
128
129    #[test]
130    fn test_write_tool_schema() {
131        let tool = WriteTool;
132        assert_eq!(tool.name(), "write");
133        assert!(!tool.description().is_empty());
134
135        let params = tool.parameters();
136        assert_eq!(params["type"], "object");
137        assert!(params["properties"].is_object());
138        assert!(params["required"].is_array());
139    }
140
141    #[tokio::test]
142    async fn test_write_tool_execution() {
143        let temp_dir = std::env::temp_dir();
144        let test_file = temp_dir.join("write_tool_test.txt");
145
146        let tool = WriteTool;
147        let ctx = create_tool_context();
148
149        let content = "Hello, world!\nThis is a test file.";
150        let params = json!({
151            "path": test_file.to_string_lossy(),
152            "content": content
153        });
154
155        let result = tool.execute(params, ctx).await.unwrap();
156
157        // Verify success message
158        assert!(result.contains("Wrote 2 lines"));
159        assert!(result.contains("bytes"));
160
161        // Verify file was created with correct content
162        let written_content = std::fs::read_to_string(&test_file).unwrap();
163        assert_eq!(written_content, content);
164
165        // Test parent directory creation
166        let nested_file = temp_dir.join("nested").join("dir").join("test.txt");
167        let ctx = create_tool_context();
168        let params = json!({
169            "path": nested_file.to_string_lossy(),
170            "content": "nested content"
171        });
172
173        let result = tool.execute(params, ctx).await.unwrap();
174        assert!(result.contains("Wrote"));
175
176        let nested_content = std::fs::read_to_string(&nested_file).unwrap();
177        assert_eq!(nested_content, "nested content");
178
179        // Cleanup
180        let _ = std::fs::remove_file(&test_file);
181        let _ = std::fs::remove_dir_all(temp_dir.join("nested"));
182    }
183}