telos_agent/tools/builtin/
file_write.rs1use async_trait::async_trait;
7use serde_json::{Value, json};
8
9use crate::error::AgentError;
10use crate::tools::api::{PermissionDecision, Tool, ToolContext, ToolDefinition, ToolOutput};
11
12use super::{
13 canonicalize_within_cwd, ensure_file_was_read_and_unchanged, modified_timestamp_ms,
14 required_string, required_string_any, resolve_workspace_path,
15};
16
17pub struct FileWriteTool;
19
20const WRITE_CONTENT_PREVIEW_CHARS: usize = 20_000;
21
22#[async_trait]
23impl Tool for FileWriteTool {
24 fn definition(&self) -> ToolDefinition {
25 ToolDefinition {
26 name: "Write".into(),
27 description: "Create or overwrite a UTF-8 text file. Existing files must be read first with Read.".into(),
28 input_schema: json!({
29 "type": "object",
30 "properties": {
31 "file_path": { "type": "string" },
32 "content": { "type": "string" }
33 },
34 "required": ["file_path", "content"]
35 }),
36 }
37 }
38
39 fn prompt_text(&self) -> Option<&'static str> {
40 Some(
41 "Use Write to create a new file or overwrite an existing UTF-8 text file. \
42If the file already exists, Read it first. Prefer Edit for small changes to existing files. \
43Create parent directories automatically.",
44 )
45 }
46
47 async fn validate(&self, arguments: &Value, _context: &ToolContext) -> Result<(), AgentError> {
48 required_string_any(arguments, &["file_path", "path"])?;
49 required_string(arguments, "content")?;
50 Ok(())
51 }
52
53 async fn check_permission(
54 &self,
55 _arguments: &Value,
56 _context: &ToolContext,
57 ) -> Result<PermissionDecision, AgentError> {
58 Ok(PermissionDecision::Ask { reason: "file write requires approval".into() })
59 }
60
61 async fn invoke(
62 &self,
63 arguments: Value,
64 context: ToolContext,
65 ) -> Result<ToolOutput, AgentError> {
66 let input_path = required_string_any(&arguments, &["file_path", "path"])?;
67 let path = resolve_workspace_path(&context.cwd, input_path)?;
68 let path = canonicalize_within_cwd(&context.cwd, &path).await?;
69 let content = required_string(&arguments, "content")?;
70 if let Ok(existing_content) = tokio::fs::read_to_string(&path).await {
71 ensure_file_was_read_and_unchanged("Write", &context, &path, &existing_content).await?;
72 }
73 if let Some(parent) = path.parent() {
75 tokio::fs::create_dir_all(parent).await.map_err(|err| AgentError::ToolExecution {
76 tool: "Write".into(),
77 message: err.to_string(),
78 })?;
79 }
80 tokio::fs::write(&path, content).await.map_err(|err| AgentError::ToolExecution {
81 tool: "Write".into(),
82 message: err.to_string(),
83 })?;
84 let timestamp_ms = modified_timestamp_ms(&path).await?;
85 context.read_file_state.lock().await.insert(
86 path.clone(),
87 crate::tools::api::FileReadRecord {
88 content: content.to_string(),
89 timestamp_ms,
90 is_partial_view: false,
91 offset: None,
92 limit: None,
93 },
94 );
95 let (content_preview, content_truncated) = content_preview(content);
96 Ok(ToolOutput::json(json!({
97 "file_path": input_path,
98 "path": path,
99 "written": true,
100 "bytes": content.len(),
101 "content_preview": content_preview,
102 "content_truncated": content_truncated,
103 })))
104 }
105}
106
107fn content_preview(content: &str) -> (String, bool) {
108 if content.chars().count() <= WRITE_CONTENT_PREVIEW_CHARS {
109 return (content.to_string(), false);
110 }
111
112 (content.chars().take(WRITE_CONTENT_PREVIEW_CHARS).collect(), true)
113}