1use agent_client_protocol::schema::v2 as acp;
2use base64::Engine;
3use base64::engine::general_purpose::STANDARD as BASE64;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6use url::Url;
7
8const IMAGE_ATTACHMENT_LABEL: &str = "image attachment";
9const AUDIO_ATTACHMENT_LABEL: &str = "audio attachment";
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct PromptAttachment {
13 pub path: PathBuf,
14 pub display_name: String,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum AttachmentKind {
19 Text,
20 Image,
21 Audio,
22 Unsupported,
23}
24
25pub struct AttachmentOutcome {
26 pub blocks: Vec<acp::ContentBlock>,
27 pub placeholders: Vec<String>,
28 pub warnings: Vec<String>,
29}
30
31pub fn classify_attachment(path: &Path) -> AttachmentKind {
32 let mime = mime_guess::from_path(path).first_or_octet_stream().to_string();
33 if IMAGE_MIME_TYPES.contains(&mime.as_str()) {
34 AttachmentKind::Image
35 } else if AUDIO_MIME_TYPES.contains(&mime.as_str()) {
36 AttachmentKind::Audio
37 } else if mime.starts_with("text/") {
38 AttachmentKind::Text
39 } else {
40 AttachmentKind::Unsupported
41 }
42}
43
44pub fn build_attachments(attachments: &[PromptAttachment]) -> AttachmentOutcome {
45 build_attachments_with(attachments, read_capped)
46}
47
48pub(crate) fn build_attachments_with(
52 attachments: &[PromptAttachment],
53 mut read: impl FnMut(&Path, &str) -> Result<Vec<u8>, String>,
54) -> AttachmentOutcome {
55 let mut outcome = AttachmentOutcome { blocks: Vec::new(), placeholders: Vec::new(), warnings: Vec::new() };
56 for attachment in attachments {
57 let encoded = read(&attachment.path, &attachment.display_name)
58 .and_then(|bytes| encode_attachment(&attachment.path, &attachment.display_name, bytes));
59 match encoded {
60 Ok((block, placeholder, warning)) => {
61 outcome.blocks.push(block);
62 if let Some(placeholder) = placeholder {
63 outcome.placeholders.push(placeholder);
64 }
65 if let Some(warning) = warning {
66 outcome.warnings.push(warning);
67 }
68 }
69 Err(warning) => outcome.warnings.push(warning),
70 }
71 }
72 outcome
73}
74
75pub(crate) fn read_capped(path: &Path, display_name: &str) -> Result<Vec<u8>, String> {
78 let mut bytes = Vec::new();
79 std::fs::File::open(path)
80 .map_err(|error| format!("Failed to read {display_name}: {error}"))?
81 .take((MAX_MEDIA_BYTES + 1) as u64)
82 .read_to_end(&mut bytes)
83 .map_err(|error| format!("Failed to read {display_name}: {error}"))?;
84 Ok(bytes)
85}
86
87const MAX_EMBED_TEXT_BYTES: usize = 1024 * 1024;
88const MAX_MEDIA_BYTES: usize = 10 * 1024 * 1024;
89const IMAGE_MIME_TYPES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];
90const AUDIO_MIME_TYPES: &[&str] = &["audio/wav", "audio/mpeg", "audio/mp3", "audio/ogg"];
91
92pub(crate) fn encode_attachment(
94 path: &Path,
95 display_name: &str,
96 bytes: Vec<u8>,
97) -> Result<(acp::ContentBlock, Option<String>, Option<String>), String> {
98 let mime_type = mime_guess::from_path(path).first_or_octet_stream().to_string();
99 match classify_attachment(path) {
100 AttachmentKind::Image | AttachmentKind::Audio => {
101 encode_media_block(&bytes, display_name, &mime_type).map(|(block, placeholder)| (block, placeholder, None))
102 }
103 AttachmentKind::Text | AttachmentKind::Unsupported => encode_text_block(bytes, path, display_name, &mime_type),
104 }
105}
106
107fn encode_media_block(
108 bytes: &[u8],
109 display_name: &str,
110 mime_type: &str,
111) -> Result<(acp::ContentBlock, Option<String>), String> {
112 if bytes.len() > MAX_MEDIA_BYTES {
113 return Err(format!("Skipped {display_name}: file too large (max {MAX_MEDIA_BYTES})"));
114 }
115 let data = BASE64.encode(bytes);
116 let (block, placeholder) = if IMAGE_MIME_TYPES.contains(&mime_type) {
117 (
118 acp::ContentBlock::Image(acp::ImageContent::new(data, mime_type)),
119 format!("[{IMAGE_ATTACHMENT_LABEL}: {display_name}]"),
120 )
121 } else {
122 (
123 acp::ContentBlock::Audio(acp::AudioContent::new(data, mime_type)),
124 format!("[{AUDIO_ATTACHMENT_LABEL}: {display_name}]"),
125 )
126 };
127 Ok((block, Some(placeholder)))
128}
129
130fn encode_text_block(
131 mut bytes: Vec<u8>,
132 path: &Path,
133 display_name: &str,
134 mime_type: &str,
135) -> Result<(acp::ContentBlock, Option<String>, Option<String>), String> {
136 let truncated = bytes.len() > MAX_EMBED_TEXT_BYTES;
137 if truncated {
138 bytes.truncate(MAX_EMBED_TEXT_BYTES);
139 }
140
141 let text = match std::str::from_utf8(&bytes) {
142 Ok(text) => text.to_string(),
143 Err(error) if truncated && error.error_len().is_none() => {
147 std::str::from_utf8(&bytes[..error.valid_up_to()]).expect("valid_up_to marks a UTF-8 boundary").to_string()
148 }
149 Err(_) => return Err(format!("Skipped binary or non-UTF8 file: {display_name}")),
150 };
151
152 let uri = attachment_uri(path, display_name)?;
153 let warning = truncated.then(|| format!("Truncated {display_name} to {MAX_EMBED_TEXT_BYTES} bytes"));
154 Ok((
155 acp::ContentBlock::Resource(acp::EmbeddedResource::new(acp::EmbeddedResourceResource::TextResourceContents(
156 acp::TextResourceContents::new(text, uri).mime_type(mime_type),
157 ))),
158 None,
159 warning,
160 ))
161}
162
163fn attachment_uri(path: &Path, display_name: &str) -> Result<String, String> {
164 let uri_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
165 Url::from_file_path(uri_path)
166 .map(|url| url.to_string())
167 .map_err(|()| format!("Failed to build file URI for {display_name}"))
168}