Skip to main content

telos_agent/tools/builtin/
file_write.rs

1//! `Write` tool — overwrite a UTF-8 text file inside the workspace.
2//!
3//! Always returns [`PermissionDecision::Ask`] — writes are mutating, so the
4//! host gets the final say.
5
6use 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
17/// Built-in file-write tool. Writes (and creates) text files inside the workspace.
18pub 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        // Create any missing parent directories so the model can write nested paths in one call.
74        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}