Skip to main content

zag_agent/
attachment.rs

1//! File attachment support for embedding files in agent prompts.
2//!
3//! Since upstream agent CLIs only accept text prompts, file attachments are
4//! embedded directly in the prompt using an XML envelope. Text files (≤50 KB)
5//! are inlined verbatim; binary files and large text files are included as
6//! metadata references with `@path` so the agent can use its own tools to
7//! access them.
8
9use anyhow::{Context, Result, bail};
10use std::path::{Path, PathBuf};
11
12/// Maximum file size allowed for attachments (10 MB).
13const MAX_ATTACHMENT_SIZE: u64 = 10 * 1024 * 1024;
14
15/// Maximum size for inline text content (50 KB). Text files larger than this
16/// are included as references instead of being inlined.
17const MAX_INLINE_SIZE: u64 = 50 * 1024;
18
19/// The content of a file attachment.
20#[derive(Debug)]
21pub enum AttachmentContent {
22    /// Text file content inlined verbatim (≤50 KB).
23    Text(String),
24    /// Binary file or large text file — metadata only, no content.
25    Reference,
26}
27
28/// A resolved file attachment ready for embedding in a prompt.
29#[derive(Debug)]
30pub struct Attachment {
31    pub path: PathBuf,
32    pub filename: String,
33    pub mime_type: String,
34    pub size: u64,
35    pub content: AttachmentContent,
36}
37
38impl Attachment {
39    /// Load an attachment from a file path.
40    ///
41    /// The file must exist and be ≤10 MB. Text files ≤50 KB are read into
42    /// memory; everything else becomes a reference.
43    pub fn from_path(path: &Path) -> Result<Self> {
44        let path = path
45            .canonicalize()
46            .with_context(|| format!("File not found: {}", path.display()))?;
47
48        let metadata = std::fs::metadata(&path)
49            .with_context(|| format!("Cannot read file metadata: {}", path.display()))?;
50
51        let size = metadata.len();
52        if size > MAX_ATTACHMENT_SIZE {
53            bail!(
54                "File too large: {} ({} bytes, max {} bytes)",
55                path.display(),
56                size,
57                MAX_ATTACHMENT_SIZE
58            );
59        }
60
61        let filename = path
62            .file_name()
63            .map(|n| n.to_string_lossy().to_string())
64            .unwrap_or_else(|| "unknown".to_string());
65
66        let ext = path
67            .extension()
68            .map(|e| e.to_string_lossy().to_lowercase())
69            .unwrap_or_default();
70
71        let mime_type = mime_from_extension(&ext).to_string();
72        let is_text = is_text_mime(&mime_type);
73
74        let content = if is_text && size <= MAX_INLINE_SIZE {
75            let text = std::fs::read_to_string(&path)
76                .with_context(|| format!("Failed to read text file: {}", path.display()))?;
77            AttachmentContent::Text(text)
78        } else {
79            AttachmentContent::Reference
80        };
81
82        Ok(Self {
83            path,
84            filename,
85            mime_type,
86            size,
87            content,
88        })
89    }
90}
91
92/// Format attachments as an XML prefix to prepend to a prompt.
93pub fn format_attachments_prefix(attachments: &[Attachment]) -> String {
94    let mut out = String::from("<attached-files>\n");
95    for att in attachments {
96        match &att.content {
97            AttachmentContent::Text(text) => {
98                out.push_str(&format!(
99                    "<file name=\"{}\" path=\"{}\" mime=\"{}\" size=\"{}\" encoding=\"utf-8\">\n{}\n</file>\n",
100                    att.filename,
101                    att.path.display(),
102                    att.mime_type,
103                    att.size,
104                    text,
105                ));
106            }
107            AttachmentContent::Reference => {
108                let encoding = if is_text_mime(&att.mime_type) {
109                    "utf-8"
110                } else {
111                    "binary"
112                };
113                out.push_str(&format!(
114                    "<file name=\"{}\" path=\"{}\" mime=\"{}\" size=\"{}\" encoding=\"{}\">\n\
115                     (content not included, use @{} to access this file)\n</file>\n",
116                    att.filename,
117                    att.path.display(),
118                    att.mime_type,
119                    att.size,
120                    encoding,
121                    att.path.display(),
122                ));
123            }
124        }
125    }
126    out.push_str("</attached-files>\n\n");
127    out
128}
129
130/// Look up a MIME type from a file extension.
131fn mime_from_extension(ext: &str) -> &'static str {
132    match ext {
133        // Text
134        "txt" | "text" => "text/plain",
135        "md" | "markdown" => "text/markdown",
136        "html" | "htm" => "text/html",
137        "css" => "text/css",
138        "csv" => "text/csv",
139        "xml" => "text/xml",
140        "svg" => "image/svg+xml",
141        // Code
142        "rs" => "text/x-rust",
143        "py" => "text/x-python",
144        "js" | "mjs" | "cjs" => "text/javascript",
145        "ts" | "mts" | "cts" => "text/typescript",
146        "tsx" | "jsx" => "text/typescript",
147        "java" => "text/x-java",
148        "kt" | "kts" => "text/x-kotlin",
149        "swift" => "text/x-swift",
150        "cs" => "text/x-csharp",
151        "go" => "text/x-go",
152        "rb" => "text/x-ruby",
153        "php" => "text/x-php",
154        "c" | "h" => "text/x-c",
155        "cpp" | "cc" | "cxx" | "hpp" | "hh" => "text/x-c++",
156        "sh" | "bash" | "zsh" | "fish" => "text/x-shellscript",
157        "sql" => "text/x-sql",
158        "r" => "text/x-r",
159        "lua" => "text/x-lua",
160        "pl" | "pm" => "text/x-perl",
161        "scala" => "text/x-scala",
162        "zig" => "text/x-zig",
163        "hs" => "text/x-haskell",
164        "ex" | "exs" => "text/x-elixir",
165        "dart" => "text/x-dart",
166        "v" | "sv" => "text/x-verilog",
167        "vhd" | "vhdl" => "text/x-vhdl",
168        // Config/data
169        "json" => "application/json",
170        "jsonl" | "ndjson" => "application/x-ndjson",
171        "yaml" | "yml" => "application/yaml",
172        "toml" => "application/toml",
173        "ini" | "cfg" | "conf" => "text/plain",
174        "env" => "text/plain",
175        "lock" => "text/plain",
176        "log" => "text/plain",
177        // Documents
178        "pdf" => "application/pdf",
179        "doc" | "docx" => "application/msword",
180        "xls" | "xlsx" => "application/vnd.ms-excel",
181        "ppt" | "pptx" => "application/vnd.ms-powerpoint",
182        // Images
183        "png" => "image/png",
184        "jpg" | "jpeg" => "image/jpeg",
185        "gif" => "image/gif",
186        "webp" => "image/webp",
187        "bmp" => "image/bmp",
188        "ico" => "image/x-icon",
189        "tiff" | "tif" => "image/tiff",
190        // Audio/Video
191        "mp3" => "audio/mpeg",
192        "wav" => "audio/wav",
193        "ogg" => "audio/ogg",
194        "mp4" => "video/mp4",
195        "webm" => "video/webm",
196        "avi" => "video/x-msvideo",
197        // Archives
198        "zip" => "application/zip",
199        "gz" | "gzip" => "application/gzip",
200        "tar" => "application/x-tar",
201        "bz2" => "application/x-bzip2",
202        "xz" => "application/x-xz",
203        "7z" => "application/x-7z-compressed",
204        // Binary
205        "wasm" => "application/wasm",
206        "exe" | "dll" | "so" | "dylib" => "application/octet-stream",
207        // Makefile, Dockerfile, etc. — no extension
208        _ => "application/octet-stream",
209    }
210}
211
212/// Returns `true` if the MIME type represents text content that can be inlined.
213fn is_text_mime(mime: &str) -> bool {
214    mime.starts_with("text/")
215        || mime == "application/json"
216        || mime == "application/x-ndjson"
217        || mime == "application/yaml"
218        || mime == "application/toml"
219        || mime == "application/xml"
220        || mime == "image/svg+xml"
221}
222
223#[cfg(test)]
224#[path = "attachment_tests.rs"]
225mod tests;