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 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 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 assert!(result.contains("Wrote 2 lines"));
159 assert!(result.contains("bytes"));
160
161 let written_content = std::fs::read_to_string(&test_file).unwrap();
163 assert_eq!(written_content, content);
164
165 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 let _ = std::fs::remove_file(&test_file);
181 let _ = std::fs::remove_dir_all(temp_dir.join("nested"));
182 }
183}