agent_engine/tools/
write.rs1use 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 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 assert!(result.contains("Wrote 2 lines"));
151 assert!(result.contains("bytes"));
152
153 let written_content = std::fs::read_to_string(&test_file).unwrap();
155 assert_eq!(written_content, content);
156
157 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 let _ = std::fs::remove_file(&test_file);
173 let _ = std::fs::remove_dir_all(temp_dir.join("nested"));
174 }
175}