1use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use futures::FutureExt as _;
10use futures::future::BoxFuture;
11use pi_agent::{AgentTool, AgentToolResult, ToolError, ToolUpdates};
12use pi_ai::types::{TextContent, ToolResultContent};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16use tokio_util::sync::CancellationToken;
17
18use super::{MutationQueueError, PathResolveError, resolve_to_cwd, with_file_mutation_queue};
19
20#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
22pub struct WriteToolInput {
23 #[schemars(description = "Path to the file to write (relative or absolute)")]
25 pub path: String,
26 #[schemars(description = "Content to write to the file")]
28 pub content: String,
29}
30
31pub type WriteToolDetails = ();
33
34#[derive(Clone, Debug)]
36pub struct WriteToolOptions {
37 pub cwd: PathBuf,
39}
40
41impl WriteToolOptions {
42 #[must_use]
44 pub fn new(cwd: impl Into<PathBuf>) -> Self {
45 Self { cwd: cwd.into() }
46 }
47}
48
49#[derive(Clone, Debug)]
51pub struct WriteTool {
52 cwd: PathBuf,
53 parameters: Value,
54 description: String,
55}
56
57impl WriteTool {
58 #[must_use]
60 pub fn new(cwd: impl Into<PathBuf>) -> Self {
61 Self::with_options(WriteToolOptions::new(cwd))
62 }
63
64 #[must_use]
66 pub fn with_options(options: WriteToolOptions) -> Self {
67 Self {
68 cwd: options.cwd,
69 parameters: write_parameters_schema(),
70 description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.".to_owned(),
71 }
72 }
73
74 #[must_use]
76 pub fn parameters_schema() -> Value {
77 write_parameters_schema()
78 }
79
80 pub fn parse_input(args: &Map<String, Value>) -> Result<WriteToolInput, ToolError> {
86 serde_json::from_value(Value::Object(args.clone()))
87 .map_err(|error| ToolError::new(format!("Write tool input is invalid. {error}")))
88 }
89
90 #[must_use]
92 pub fn utf16_length(content: &str) -> usize {
93 content.encode_utf16().count()
94 }
95
96 #[must_use]
98 pub fn success_text(path: &str, content: &str) -> String {
99 format!(
100 "Successfully wrote {} bytes to {path}",
101 Self::utf16_length(content)
102 )
103 }
104}
105
106impl AgentTool for WriteTool {
107 fn name(&self) -> &'static str {
108 "write"
109 }
110
111 fn label(&self) -> &'static str {
112 "write"
113 }
114
115 fn description(&self) -> &str {
116 &self.description
117 }
118
119 fn parameters(&self) -> &Value {
120 &self.parameters
121 }
122
123 fn validate_arguments(
124 &self,
125 args: &Map<String, Value>,
126 ) -> Result<Map<String, Value>, ToolError> {
127 let _ = Self::parse_input(args)?;
128 Ok(args.clone())
129 }
130
131 fn execute(
132 &self,
133 _tool_call_id: &str,
134 args: Map<String, Value>,
135 cancel: CancellationToken,
136 _updates: ToolUpdates,
137 ) -> BoxFuture<'static, Result<AgentToolResult, ToolError>> {
138 let cwd = self.cwd.clone();
139 async move {
140 throw_if_cancelled(&cancel)?;
141 let input = WriteTool::parse_input(&args)?;
142 let absolute_path = resolve_to_cwd(&input.path, cwd.to_string_lossy().as_ref())
143 .map_err(|error| path_error(&error))?;
144 let absolute = PathBuf::from(&absolute_path);
145 let parent = absolute
146 .parent()
147 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
148 let path_for_message = input.path.clone();
149 let content = input.content;
150 let cancel_for_queue = cancel.clone();
151
152 with_file_mutation_queue(&absolute, || {
153 let parent = parent.clone();
154 let absolute = absolute.clone();
155 let content = content.clone();
156 let cancel = cancel_for_queue.clone();
157 async move {
158 apply_write_mutation_with_commit_hooks(
159 &parent,
160 &absolute,
161 &path_for_message,
162 &content,
163 &cancel,
164 || {},
165 || {},
166 )
167 .await
168 }
169 })
170 .await
171 .map_err(|error| mutation_error(&error))?
172 }
173 .boxed()
174 }
175}
176
177async fn apply_write_mutation_with_commit_hooks<BeforeCommit, AfterCommit>(
178 parent: &Path,
179 absolute: &Path,
180 path_for_message: &str,
181 content: &str,
182 cancel: &CancellationToken,
183 before_commit: BeforeCommit,
184 after_commit: AfterCommit,
185) -> Result<AgentToolResult, ToolError>
186where
187 BeforeCommit: FnOnce() + Send,
188 AfterCommit: FnOnce() + Send,
189{
190 throw_if_cancelled(cancel)?;
191
192 tokio::fs::create_dir_all(parent).await.map_err(|error| {
193 ToolError::new(format!(
194 "Could not create parent directories for {}: {error}",
195 absolute.display()
196 ))
197 })?;
198
199 let result = AgentToolResult {
200 content: vec![ToolResultContent::Text(TextContent::new(
201 WriteTool::success_text(path_for_message, content),
202 ))],
203 details: Value::Null,
204 added_tool_names: None,
205 terminate: None,
206 };
207
208 before_commit();
209 throw_if_cancelled(cancel)?;
210 tokio::fs::write(absolute, content.as_bytes())
211 .await
212 .map_err(|error| {
213 ToolError::new(format!(
214 "Could not write file {}: {error}",
215 absolute.display()
216 ))
217 })?;
218 after_commit();
219
220 Ok(result)
223}
224
225fn write_parameters_schema() -> Value {
226 normalize_tool_schema(schemars::schema_for!(WriteToolInput))
227}
228
229fn normalize_tool_schema(schema: schemars::Schema) -> Value {
230 let mut value = serde_json::to_value(schema).unwrap_or_else(|_| Value::Object(Map::new()));
231 if let Value::Object(map) = &mut value {
232 map.remove("$schema");
233 map.remove("title");
234 map.remove("description");
235 map.remove("additionalProperties");
236 normalize_schema_node(map);
237 }
238 value
239}
240
241fn normalize_schema_node(map: &mut Map<String, Value>) {
242 map.remove("format");
243 if let Some(Value::Array(types)) = map.get("type").cloned() {
244 let non_null: Vec<Value> = types
245 .into_iter()
246 .filter(|t| t.as_str() != Some("null"))
247 .collect();
248 if non_null.len() == 1 {
249 map.insert("type".to_owned(), non_null[0].clone());
250 } else if !non_null.is_empty() {
251 map.insert("type".to_owned(), Value::Array(non_null));
252 }
253 }
254 let keys: Vec<String> = map.keys().cloned().collect();
255 for key in keys {
256 match map.get_mut(&key) {
257 Some(Value::Object(child)) => normalize_schema_node(child),
258 Some(Value::Array(items)) => {
259 for item in items {
260 if let Value::Object(child) = item {
261 normalize_schema_node(child);
262 }
263 }
264 }
265 _ => {}
266 }
267 }
268}
269
270fn throw_if_cancelled(cancel: &CancellationToken) -> Result<(), ToolError> {
271 if cancel.is_cancelled() {
272 Err(ToolError::new("Operation aborted"))
273 } else {
274 Ok(())
275 }
276}
277
278fn path_error(error: &PathResolveError) -> ToolError {
279 ToolError::new(error.to_string())
280}
281
282fn mutation_error(error: &MutationQueueError) -> ToolError {
283 ToolError::new(error.to_string())
284}
285
286#[must_use]
288pub fn create_write_tool(cwd: impl Into<PathBuf>) -> Arc<dyn AgentTool> {
289 Arc::new(WriteTool::new(cwd))
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use std::sync::Arc;
296 use std::time::Duration;
297
298 use serde_json::json;
299 use tempfile::tempdir;
300 use tokio::sync::Barrier;
301
302 fn fixture_schema() -> Result<Value, serde_json::Error> {
303 let text = include_str!("../../../tests/fixtures/tool-schemas/write.json");
304 serde_json::from_str(text)
305 }
306
307 #[test]
308 fn schema_matches_typebox_fixture() -> Result<(), Box<dyn std::error::Error>> {
309 let schema = WriteTool::parameters_schema();
310 assert_eq!(schema, fixture_schema()?);
311 Ok(())
312 }
313
314 #[test]
315 fn utf16_length_counts_surrogate_pairs() {
316 assert_eq!(WriteTool::utf16_length("😀"), 2);
318 assert_eq!(WriteTool::utf16_length("abc"), 3);
319 assert_eq!(
320 WriteTool::success_text("a.txt", "😀"),
321 "Successfully wrote 2 bytes to a.txt"
322 );
323 }
324
325 #[tokio::test]
326 async fn writes_create_parent_and_overwrite() -> Result<(), Box<dyn std::error::Error>> {
327 let dir = tempdir()?;
328 let tool = WriteTool::new(dir.path());
329 let nested = "sub/dir/file.txt";
330 let args = json_map(json!({
331 "path": nested,
332 "content": "hello"
333 }))?;
334 let result = tool
335 .execute("1", args, CancellationToken::new(), ToolUpdates::noop())
336 .await?;
337 let text = text_of(&result);
338 assert_eq!(text, "Successfully wrote 5 bytes to sub/dir/file.txt");
339 let path = dir.path().join(nested);
340 assert_eq!(tokio::fs::read_to_string(&path).await?, "hello");
341
342 let args = json_map(json!({
343 "path": nested,
344 "content": "overwrite"
345 }))?;
346 let result = tool
347 .execute("2", args, CancellationToken::new(), ToolUpdates::noop())
348 .await?;
349 assert_eq!(
350 text_of(&result),
351 "Successfully wrote 9 bytes to sub/dir/file.txt"
352 );
353 assert_eq!(tokio::fs::read_to_string(&path).await?, "overwrite");
354 Ok(())
355 }
356
357 #[tokio::test]
358 async fn cancellation_before_work_aborts() -> Result<(), Box<dyn std::error::Error>> {
359 let dir = tempdir()?;
360 let tool = WriteTool::new(dir.path());
361 let cancel = CancellationToken::new();
362 cancel.cancel();
363 let result = tool
364 .execute(
365 "1",
366 json_map(json!({"path": "a.txt", "content": "x"}))?,
367 cancel,
368 ToolUpdates::noop(),
369 )
370 .await;
371 let Err(err) = result else {
372 return Err("expected cancellation error".into());
373 };
374 assert_eq!(err.message(), "Operation aborted");
375 Ok(())
376 }
377
378 #[tokio::test]
379 async fn cancellation_before_write_commit_aborts_without_mutating()
380 -> Result<(), Box<dyn std::error::Error>> {
381 let dir = tempdir()?;
382 let path = dir.path().join("pre-commit.txt");
383 tokio::fs::write(&path, "before").await?;
384 let cancel = CancellationToken::new();
385 let cancel_at_boundary = cancel.clone();
386
387 let result = apply_write_mutation_with_commit_hooks(
388 dir.path(),
389 &path,
390 "pre-commit.txt",
391 "after",
392 &cancel,
393 move || cancel_at_boundary.cancel(),
394 || {},
395 )
396 .await;
397 let Err(error) = result else {
398 return Err("pre-commit cancellation unexpectedly succeeded".into());
399 };
400
401 assert_eq!(error.message(), "Operation aborted");
402 assert_eq!(tokio::fs::read_to_string(&path).await?, "before");
403 Ok(())
404 }
405
406 #[tokio::test]
407 async fn cancellation_after_write_commit_reports_success()
408 -> Result<(), Box<dyn std::error::Error>> {
409 let dir = tempdir()?;
410 let path = dir.path().join("post-commit.txt");
411 let cancel = CancellationToken::new();
412 let cancel_at_boundary = cancel.clone();
413
414 let result = apply_write_mutation_with_commit_hooks(
415 dir.path(),
416 &path,
417 "post-commit.txt",
418 "committed",
419 &cancel,
420 || {},
421 move || cancel_at_boundary.cancel(),
422 )
423 .await?;
424
425 assert!(cancel.is_cancelled());
426 assert_eq!(
427 text_of(&result),
428 "Successfully wrote 9 bytes to post-commit.txt"
429 );
430 assert_eq!(tokio::fs::read_to_string(&path).await?, "committed");
431 Ok(())
432 }
433
434 #[tokio::test]
435 async fn serialized_writes_same_path() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
436 let dir = tempdir()?;
437 let path = dir.path().join("serial.txt");
438 let barrier = Arc::new(Barrier::new(2));
439 let order = Arc::new(std::sync::Mutex::new(Vec::new()));
440
441 let start_a = barrier.clone();
442 let order_a = order.clone();
443 let path_a = path.clone();
444 let a = tokio::spawn(async move {
445 with_file_mutation_queue(&path_a, || {
446 let start_a = start_a.clone();
447 let order_a = order_a.clone();
448 let path_a = path_a.clone();
449 async move {
450 start_a.wait().await;
451 order_a
452 .lock()
453 .map_err(|_| std::io::Error::other("lock poisoned"))?
454 .push("a-start");
455 tokio::time::sleep(Duration::from_millis(40)).await;
456 tokio::fs::write(&path_a, b"a").await?;
457 order_a
458 .lock()
459 .map_err(|_| std::io::Error::other("lock poisoned"))?
460 .push("a-end");
461 Ok::<(), std::io::Error>(())
462 }
463 })
464 .await
465 .map_err(|error| std::io::Error::other(error.to_string()))?
466 });
467
468 let start_b = barrier;
469 let order_b = order.clone();
470 let path_b = path.clone();
471 let b = tokio::spawn(async move {
472 start_b.wait().await;
474 with_file_mutation_queue(&path_b, || {
475 let order_b = order_b.clone();
476 let path_b = path_b.clone();
477 async move {
478 order_b
479 .lock()
480 .map_err(|_| std::io::Error::other("lock poisoned"))?
481 .push("b-start");
482 tokio::fs::write(&path_b, b"b").await?;
483 order_b
484 .lock()
485 .map_err(|_| std::io::Error::other("lock poisoned"))?
486 .push("b-end");
487 Ok::<(), std::io::Error>(())
488 }
489 })
490 .await
491 .map_err(|error| std::io::Error::other(error.to_string()))?
492 });
493
494 a.await??;
495 b.await??;
496 let observed = order
497 .lock()
498 .map_err(|_| std::io::Error::other("lock poisoned"))?
499 .clone();
500 assert!(
503 observed == ["a-start", "a-end", "b-start", "b-end"]
504 || observed == ["b-start", "b-end", "a-start", "a-end"],
505 "unexpected order: {observed:?}"
506 );
507 Ok(())
508 }
509
510 fn json_map(value: Value) -> Result<Map<String, Value>, &'static str> {
511 if let Value::Object(map) = value {
512 Ok(map)
513 } else {
514 Err("expected JSON object")
515 }
516 }
517
518 fn text_of(result: &AgentToolResult) -> String {
519 match result.content.first() {
520 Some(ToolResultContent::Text(text)) => text.text.to_string(),
521 _ => String::new(),
522 }
523 }
524}