Skip to content

Commit 394fa4b

Browse files
committed
[feat] Update media structure and OS routers for Agent platform
1 parent d918920 commit 394fa4b

12 files changed

Lines changed: 458 additions & 139 deletions

File tree

libs/agno/agno/knowledge/reader/reader_factory.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import Any, Callable, Dict, List, Optional
33

44
from agno.knowledge.reader.base import Reader
5+
from agno.utils.common import MIME_TO_EXTENSION
56

67

78
class ReaderFactory:
@@ -364,30 +365,32 @@ def create_reader(cls, reader_key: str, **kwargs) -> Reader:
364365

365366
@classmethod
366367
def get_reader_for_extension(cls, extension: str) -> Reader:
367-
"""Get the appropriate reader for a file extension."""
368-
# TODO: add docling for unique file extensions eg: images, audios, etc.
369-
extension = extension.lower()
370-
371-
if extension in [".pdf", "application/pdf"]:
368+
"""Get the appropriate reader for a file extension or MIME type."""
369+
# 1. Standardize the input: lower() and remove optional leading dot
370+
ext = extension.lower().strip()
371+
if ext.startswith("."):
372+
ext = ext[1:]
373+
374+
# 2. Check if the input is a full MIME type and convert to short extension
375+
if ext in MIME_TO_EXTENSION:
376+
ext = MIME_TO_EXTENSION[ext]
377+
378+
# 3. Route to the specialized reader based on the normalized format
379+
if ext == "pdf":
372380
return cls.create_reader("pdf")
373-
elif extension in [".csv", "text/csv"]:
381+
elif ext == "csv":
374382
return cls.create_reader("csv")
375-
elif extension in [
376-
".xlsx",
377-
".xls",
378-
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
379-
"application/vnd.ms-excel",
380-
]:
383+
elif ext in ["xlsx", "xls"]:
381384
return cls.create_reader("excel")
382-
elif extension in [".docx", ".doc", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"]:
385+
elif ext in ["docx", "doc"]:
383386
return cls.create_reader("docx")
384-
elif extension == ".pptx":
387+
elif ext == "pptx":
385388
return cls.create_reader("pptx")
386-
elif extension == ".json":
389+
elif ext == "json":
387390
return cls.create_reader("json")
388-
elif extension in [".md", ".markdown"]:
391+
elif ext in ["md", "markdown"]:
389392
return cls.create_reader("markdown")
390-
elif extension in [".txt", ".text"]:
393+
elif ext in ["txt", "text"]:
391394
return cls.create_reader("text")
392395
else:
393396
# Default to text reader for unknown extensions

libs/agno/agno/media.py

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from pydantic import BaseModel, field_validator, model_validator
77

8+
from agno.utils.common import MIME_TO_EXTENSION
89
from agno.utils.log import log_error
910

1011

@@ -52,6 +53,21 @@ def validate_and_normalize_content(cls, data: Any):
5253

5354
return data
5455

56+
@field_validator("mime_type")
57+
@classmethod
58+
def validate_mime_type(cls, v):
59+
"""Validate that the mime_type is one of the allowed types."""
60+
if v is not None:
61+
v_lower = v.lower()
62+
if v_lower not in cls.valid_mime_types():
63+
raise ValueError(f"Invalid MIME type: {v}. Must be one of: {cls.valid_mime_types()}")
64+
return v_lower
65+
return v
66+
67+
@classmethod
68+
def valid_mime_types(cls) -> List[str]:
69+
return [m for m, e in MIME_TO_EXTENSION.items() if m.startswith("image/")]
70+
5571
def get_content_bytes(self) -> Optional[bytes]:
5672
"""Get image content as raw bytes, loading from URL/file if needed"""
5773
if self.content:
@@ -168,6 +184,21 @@ def validate_and_normalize_content(cls, data: Any):
168184

169185
return data
170186

187+
@field_validator("mime_type")
188+
@classmethod
189+
def validate_mime_type(cls, v):
190+
"""Validate that the mime_type is one of the allowed types."""
191+
if v is not None:
192+
v_lower = v.lower()
193+
if v_lower not in cls.valid_mime_types():
194+
raise ValueError(f"Invalid MIME type: {v}. Must be one of: {cls.valid_mime_types()}")
195+
return v_lower
196+
return v
197+
198+
@classmethod
199+
def valid_mime_types(cls) -> List[str]:
200+
return [m for m, e in MIME_TO_EXTENSION.items() if m.startswith("audio/")]
201+
171202
def get_content_bytes(self) -> Optional[bytes]:
172203
"""Get audio content as raw bytes"""
173204
if self.content:
@@ -300,6 +331,21 @@ def validate_and_normalize_content(cls, data: Any):
300331

301332
return data
302333

334+
@field_validator("mime_type")
335+
@classmethod
336+
def validate_mime_type(cls, v):
337+
"""Validate that the mime_type is one of the allowed types."""
338+
if v is not None:
339+
v_lower = v.lower()
340+
if v_lower not in cls.valid_mime_types():
341+
raise ValueError(f"Invalid MIME type: {v}. Must be one of: {cls.valid_mime_types()}")
342+
return v_lower
343+
return v
344+
345+
@classmethod
346+
def valid_mime_types(cls) -> List[str]:
347+
return [m for m, e in MIME_TO_EXTENSION.items() if m.startswith("video/")]
348+
303349
def get_content_bytes(self) -> Optional[bytes]:
304350
"""Get video content as raw bytes"""
305351
if self.content:
@@ -345,7 +391,7 @@ def from_base64(
345391
format: Optional[str] = None,
346392
**kwargs,
347393
) -> "Video":
348-
"""Create Image from base64 content"""
394+
"""Create Video from base64 content"""
349395
import base64
350396

351397
try:
@@ -379,18 +425,18 @@ def to_dict(self, include_base64_content: bool = True) -> Dict[str, Any]:
379425

380426

381427
class File(BaseModel):
428+
# Core content fields (at least one required)
382429
id: Optional[str] = None
383430
url: Optional[str] = None
384431
filepath: Optional[Union[Path, str]] = None
385-
# Raw bytes content of a file
386-
content: Optional[Any] = None
387-
mime_type: Optional[str] = None
432+
content: Optional[Any] = None # Raw bytes content of a file
433+
external: Optional[Any] = None # External file object (e.g. GeminiFile)
388434

435+
# Metadata fields
436+
mime_type: Optional[str] = None
389437
file_type: Optional[str] = None
390438
filename: Optional[str] = None
391439
size: Optional[int] = None
392-
# External file object (e.g. GeminiFile, must be a valid object as expected by the model you are using)
393-
external: Optional[Any] = None
394440
format: Optional[str] = None # E.g. `pdf`, `txt`, `csv`, `xml`, etc.
395441
name: Optional[str] = None # Name of the file, mandatory for AWS Bedrock document input
396442

@@ -408,28 +454,17 @@ def check_at_least_one_source(cls, data):
408454
@classmethod
409455
def validate_mime_type(cls, v):
410456
"""Validate that the mime_type is one of the allowed types."""
411-
if v is not None and v not in cls.valid_mime_types():
412-
raise ValueError(f"Invalid MIME type: {v}. Must be one of: {cls.valid_mime_types()}")
457+
if v is not None:
458+
v_lower = v.lower()
459+
if v_lower not in cls.valid_mime_types():
460+
raise ValueError(f"Invalid MIME type: {v}. Must be one of: {cls.valid_mime_types()}")
461+
return v_lower
413462
return v
414463

415464
@classmethod
416465
def valid_mime_types(cls) -> List[str]:
417-
return [
418-
"application/pdf",
419-
"application/json",
420-
"application/x-javascript",
421-
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
422-
"text/javascript",
423-
"application/x-python",
424-
"text/x-python",
425-
"text/plain",
426-
"text/html",
427-
"text/css",
428-
"text/markdown",
429-
"text/csv",
430-
"text/xml",
431-
"text/rtf",
432-
]
466+
# Return all MIME types defined in common.py for regular Files/Documents
467+
return list(MIME_TO_EXTENSION.keys())
433468

434469
@classmethod
435470
def from_base64(

libs/agno/agno/os/routers/agents/router.py

Lines changed: 4 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -278,77 +278,28 @@ async def create_agent_run(
278278

279279
if files:
280280
for file in files:
281-
if file.content_type in [
282-
"image/png",
283-
"image/jpeg",
284-
"image/jpg",
285-
"image/gif",
286-
"image/webp",
287-
"image/bmp",
288-
"image/tiff",
289-
"image/tif",
290-
"image/avif",
291-
"image/heic",
292-
"image/heif",
293-
]:
281+
if file.content_type in Image.valid_mime_types():
294282
try:
295283
base64_image = process_image(file)
296284
base64_images.append(base64_image)
297285
except Exception as e:
298286
log_error(f"Error processing image {file.filename}: {e}")
299287
continue
300-
elif file.content_type in [
301-
"audio/wav",
302-
"audio/wave",
303-
"audio/mp3",
304-
"audio/mpeg",
305-
"audio/ogg",
306-
"audio/mp4",
307-
"audio/m4a",
308-
"audio/aac",
309-
"audio/flac",
310-
]:
288+
elif file.content_type in Audio.valid_mime_types():
311289
try:
312290
audio = process_audio(file)
313291
base64_audios.append(audio)
314292
except Exception as e:
315293
log_error(f"Error processing audio {file.filename} with content type {file.content_type}: {e}")
316294
continue
317-
elif file.content_type in [
318-
"video/x-flv",
319-
"video/quicktime",
320-
"video/mpeg",
321-
"video/mpegs",
322-
"video/mpgs",
323-
"video/mpg",
324-
"video/mpg",
325-
"video/mp4",
326-
"video/webm",
327-
"video/wmv",
328-
"video/3gpp",
329-
]:
295+
elif file.content_type in Video.valid_mime_types():
330296
try:
331297
base64_video = process_video(file)
332298
base64_videos.append(base64_video)
333299
except Exception as e:
334300
log_error(f"Error processing video {file.filename}: {e}")
335301
continue
336-
elif file.content_type in [
337-
"application/pdf",
338-
"application/json",
339-
"application/x-javascript",
340-
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
341-
"text/javascript",
342-
"application/x-python",
343-
"text/x-python",
344-
"text/plain",
345-
"text/html",
346-
"text/css",
347-
"text/markdown",
348-
"text/csv",
349-
"text/xml",
350-
"text/rtf",
351-
]:
302+
elif file.content_type in FileMedia.valid_mime_types():
352303
# Process document files
353304
try:
354305
input_file = process_document(file)

libs/agno/agno/os/routers/teams/router.py

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -225,53 +225,28 @@ async def create_team_run(
225225

226226
if files:
227227
for file in files:
228-
if file.content_type in [
229-
"image/png",
230-
"image/jpeg",
231-
"image/jpg",
232-
"image/webp",
233-
"image/heic",
234-
"image/heif",
235-
]:
228+
if file.content_type in Image.valid_mime_types():
236229
try:
237230
base64_image = process_image(file)
238231
base64_images.append(base64_image)
239232
except Exception as e:
240233
logger.error(f"Error processing image {file.filename}: {e}")
241234
continue
242-
elif file.content_type in ["audio/wav", "audio/mp3", "audio/mpeg"]:
235+
elif file.content_type in Audio.valid_mime_types():
243236
try:
244237
base64_audio = process_audio(file)
245238
base64_audios.append(base64_audio)
246239
except Exception as e:
247240
logger.error(f"Error processing audio {file.filename}: {e}")
248241
continue
249-
elif file.content_type in [
250-
"video/x-flv",
251-
"video/quicktime",
252-
"video/mpeg",
253-
"video/mpegs",
254-
"video/mpgs",
255-
"video/mpg",
256-
"video/mpg",
257-
"video/mp4",
258-
"video/webm",
259-
"video/wmv",
260-
"video/3gpp",
261-
]:
242+
elif file.content_type in Video.valid_mime_types():
262243
try:
263244
base64_video = process_video(file)
264245
base64_videos.append(base64_video)
265246
except Exception as e:
266247
logger.error(f"Error processing video {file.filename}: {e}")
267248
continue
268-
elif file.content_type in [
269-
"application/pdf",
270-
"text/csv",
271-
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
272-
"text/plain",
273-
"application/json",
274-
]:
249+
elif file.content_type in FileMedia.valid_mime_types():
275250
document_file = process_document(file)
276251
if document_file is not None:
277252
document_files.append(document_file)

libs/agno/agno/os/utils.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from agno.run.workflow import WorkflowRunOutputEvent
2222
from agno.team import RemoteTeam, Team
2323
from agno.tools import Function, Toolkit
24+
from agno.utils.common import MIME_TO_EXTENSION
2425
from agno.utils.log import log_warning, logger
2526
from agno.workflow import RemoteWorkflow, Workflow
2627

@@ -491,14 +492,25 @@ def process_document(file: UploadFile) -> Optional[FileMedia]:
491492

492493

493494
def extract_format(file: UploadFile) -> Optional[str]:
494-
"""Extract the File format from file name or content_type."""
495-
# Get the format from the filename
496-
if file.filename and "." in file.filename:
497-
return file.filename.split(".")[-1].lower()
498-
499-
# Fallback to the file content_type
495+
"""Extract a standardized file format (extension) from file name or content_type."""
496+
# Priority 1: Use filename but only the literal final extension to avoid double extension attacks
497+
if file.filename:
498+
name_parts = file.filename.split(".")
499+
if len(name_parts) > 1:
500+
ext = name_parts[-1].lower().strip()
501+
if ext:
502+
return ext
503+
504+
# Priority 2: Use explicit mapping for complex MIME types (common in Office/Google Drive)
500505
if file.content_type:
501-
return file.content_type.strip().split("/")[-1]
506+
# Handle formats like 'image/png; charset=utf-8'
507+
main_type = file.content_type.split(";")[0].strip().lower()
508+
if main_type in MIME_TO_EXTENSION:
509+
return MIME_TO_EXTENSION[main_type]
510+
511+
# Priority 3: Fallback to the last part of a standard MIME type (e.g., image/png -> png)
512+
if "/" in main_type:
513+
return main_type.split("/")[-1].lower()
502514

503515
return None
504516

0 commit comments

Comments
 (0)