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