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