feat: support .zip and .eml file uploads in AgentOS (#9193)

## Summary

Adds `.zip` and `.eml` to the set of file types AgentOS upload routers
accept. Previously these were rejected with "Unsupported file type"
(HTTP 400) because neither MIME type was present in
`DOCUMENT_MIME_TYPES`/`_DOCUMENT_EXTENSION_MIME`
(`libs/agno/agno/os/utils.py`) or `FileMedia.valid_mime_types()`
(`libs/agno/agno/media.py`).

- `.eml` (`message/rfc822`) is a standard email format, same use case as
the already-supported `.msg`.
- `.zip` (`application/zip`) is accepted as an opaque binary attachment
only — no server-side extraction or inspection of its contents.
Unpacking is left to the caller's own `pre_hooks`/workflow logic,
consistent with how other binary formats (`.doc`, `.ppt`, `.msg`) are
already passed through without being parsed by Agno itself.

Also updated `EXTENSION_CATEGORY` and `_DOCUMENT_EXTENSION_MIME` so
uploads with a missing/generic `content_type`
(`application/octet-stream` or empty) still resolve to the correct MIME
type from the filename extension, following the existing fallback
pattern used for `.md`/`.pptx`.

Closes #9191

## Type of change

- [ ] Bug fix
- [x] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated 

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull requests](../../pulls) and
confirmed that no other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

**This PR was generated with the assistance of Claude Code.** The change
was reviewed and understood by the author before submission.
---

## Additional Notes

Not all model providers can process `.zip`/`.eml` content directly (e.g.
Anthropic/Gemini reject arbitrary binary/container formats) — such
uploads succeed at the router but fail later with a provider-level
error, consistent with existing behavior for other binary Office formats
(`.doc`, `.ppt`, `.xls`).

Related: #7330, #7329, #8064

---------

Co-authored-by: luis.martins <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Kaustubh <[email protected]>
Co-authored-by: Ashpreet <[email protected]>
Co-authored-by: Ashpreet <[email protected]>
Co-authored-by: Sannya Singal <[email protected]>
Co-authored-by: Harsh <[email protected]>
Co-authored-by: Harsh Sinha <[email protected]>
This commit is contained in:
Luís Martins
2026-09-04 14:14:21 +05:30
committed by GitHub
co-authored by luis.martins Claude Sonnet 5 Kaustubh Ashpreet Ashpreet Sannya Singal Harsh Harsh Sinha
parent 0011d6f360
commit 393cee470f
8 changed files with 236 additions and 14 deletions
@@ -1,8 +1,8 @@
# Run Lifecycle
These five examples cover the lifecycle APIs around an AgentOS agent run:
These six examples cover the lifecycle APIs around an AgentOS agent run:
background submission and polling, cancellation, resumable SSE, persisted
checkpoints, and background hooks.
checkpoints, background hooks, and preprocessing uploads before the model call.
## Setup
@@ -24,6 +24,7 @@ Each server example listens on port 7777. Start only one at a time.
| `sse_reconnect.py` | Resume both a new background SSE run and a background continuation with raw `httpx`. |
| `checkpoints.py` | List `tool-batch` checkpoints and continue from a selected `message_index`. |
| `hooks_in_background.py` | Choose AgentOS-wide background hooks or mix blocking and per-hook background work. |
| `unpack_archives.py` | Replace an uploaded `.zip` with the files inside it from a `pre_hook`. |
## Run states
@@ -93,6 +93,26 @@ persisted its eval run.
---
### unpack_archives.py
**Status:** PASS
**Test mode:** LIVE
**Description:** Started the checked-in unpacking AgentOS server and uploaded
`.zip` archives over HTTP to `/agents/unpack-agent/runs` using OpenAI Responses
`gpt-5.5`. Covered a DEFLATE archive holding `invoice.txt` and `notes.md`, and a
macOS Finder archive holding a single PDF alongside its `__MACOSX` entry.
**Result:** Both runs returned `COMPLETED`. The pre-hook replaced each archive
with its contents before the model call, and the agent quoted values that exist
only inside the archives (`AGNO-9193`, `4242.00 EUR`, `Acme Corp`, `ZEBRA-7`).
The Finder archive resolved its PDF and skipped the `__MACOSX` sidecar entry.
Without the pre-hook the same upload fails at the provider with an unsupported
MIME type, since no provider unpacks an archive.
---
## Validation
- `pytest cookbook/scripts/tests/test_check_cookbook_pattern.py -q`:
@@ -0,0 +1,100 @@
"""
Unpack uploaded .zip files before the model call
================================================
AgentOS accepts a ``.zip`` upload, but no model provider opens one: the archive
is forwarded as the same compressed bytes that left the browser, so the model
sees a blob rather than the files inside it.
A ``pre_hook`` runs after the upload is received and before the model is called,
and it is handed ``run_input.files``. Replacing that list with the archive's
contents is what the model then receives, so an agent can answer from a zip
without AgentOS ever having to unpack one itself.
Prerequisites: OPENAI_API_KEY
Run: .venvs/demo/bin/python cookbook/05_agent_os/04_run_lifecycle/unpack_archives.py
Try: Upload any .zip to the Unpack Agent from the AgentOS UI and ask what is inside it
"""
import io
import mimetypes
import zipfile
from agno.agent import Agent
from agno.db.sqlite import SqliteDb
from agno.media import File
from agno.models.openai import OpenAIResponses
from agno.os import AgentOS
from agno.run.agent import RunInput
# ---------------------------------------------------------------------------
# Create Unpacking AgentOS
# ---------------------------------------------------------------------------
def unpack_archives(run_input: RunInput) -> None:
"""Replace every uploaded .zip with the files it contains."""
if not run_input.files:
return
unpacked: list[File] = []
for uploaded in run_input.files:
if uploaded.mime_type != "application/zip" or not uploaded.content:
unpacked.append(uploaded)
continue
with zipfile.ZipFile(io.BytesIO(uploaded.content)) as archive:
for entry in archive.infolist():
# Archives written by macOS Finder carry a hidden "__MACOSX/._<name>"
# entry per file holding Finder metadata rather than content.
if entry.is_dir() or entry.filename.startswith("__MACOSX/"):
continue
name = entry.filename.rsplit("/", 1)[-1]
if name.startswith("._"):
continue
# File validates mime_type, so resolve it per entry and fall back to
# None for anything outside File.valid_mime_types().
mime_type, _ = mimetypes.guess_type(name)
if mime_type not in File.valid_mime_types():
mime_type = None
unpacked.append(
File(
content=archive.read(entry),
filename=name,
format=name.rsplit(".", 1)[-1].lower(),
mime_type=mime_type,
)
)
run_input.files = unpacked
db = SqliteDb(
id="unpack-db",
db_file="tmp/agent_os_unpack.db",
)
unpack_agent = Agent(
id="unpack-agent",
name="Unpack Agent",
model=OpenAIResponses(id="gpt-5.5"),
db=db,
pre_hooks=[unpack_archives],
instructions="Answer only from the attached files and quote the exact values you read.",
)
agent_os = AgentOS(
id="unpack-os",
db=db,
agents=[unpack_agent],
)
app = agent_os.get_app()
# ---------------------------------------------------------------------------
# Run Unpacking AgentOS
# ---------------------------------------------------------------------------
if __name__ == "__main__":
agent_os.serve(app=app, port=7777)
+2
View File
@@ -761,6 +761,8 @@ class File(BaseModel):
"application/vnd.ms-powerpoint", # .ppt
"application/vnd.ms-excel", # .xls
"application/vnd.ms-outlook", # .msg
"application/zip", # .zip
"message/rfc822", # .eml
"text/javascript",
"application/x-python",
"text/x-python",
+32 -6
View File
@@ -1055,6 +1055,8 @@ DOCUMENT_MIME_TYPES = {
"application/vnd.ms-powerpoint", # .ppt
"application/vnd.ms-excel", # .xls
"application/vnd.ms-outlook", # .msg
"application/zip", # .zip
"message/rfc822", # .eml
"text/javascript",
"application/x-python",
"text/x-python",
@@ -1067,6 +1069,17 @@ DOCUMENT_MIME_TYPES = {
"text/rtf",
}
# Other registered names for types already listed above (Windows browsers send
# `application/x-zip-compressed` for `.zip`). Normalize at the upload boundary so a file
# is not rejected for arriving under its alternate spelling.
_DOCUMENT_MIME_TYPE_ALIASES = {
"application/x-zip": "application/zip",
"application/x-zip-compressed": "application/zip",
"application/xml": "text/xml",
"application/rtf": "text/rtf",
"application/javascript": "text/javascript",
}
# Fallback mapping from file extension to media category. Used when the browser sends a
# missing or ambiguous content type (e.g. `application/octet-stream` or empty for `.md`
# and `.pptx`, which are not in every OS MIME registry).
@@ -1082,6 +1095,8 @@ EXTENSION_CATEGORY: Dict[str, str] = {
"xlsx": "document",
"xls": "document",
"msg": "document",
"zip": "document",
"eml": "document",
"py": "document",
"txt": "document",
"html": "document",
@@ -1127,6 +1142,13 @@ EXTENSION_CATEGORY: Dict[str, str] = {
_AMBIGUOUS_CONTENT_TYPES = {None, "", "application/octet-stream"}
def _normalize_document_mime_type(content_type: Optional[str]) -> Optional[str]:
"""Map an alternate MIME spelling onto the type listed in DOCUMENT_MIME_TYPES."""
if content_type is None:
return None
return _DOCUMENT_MIME_TYPE_ALIASES.get(content_type, content_type)
def classify_upload_file(file: UploadFile) -> Optional[str]:
"""Classify an uploaded file into one of: image, audio, video, document.
@@ -1134,7 +1156,7 @@ def classify_upload_file(file: UploadFile) -> Optional[str]:
to be useful (common for `.md` and `.pptx` uploaded from browsers), falls back to the
filename extension. Returns None if the file type is not supported.
"""
content_type = file.content_type
content_type = _normalize_document_mime_type(file.content_type)
if content_type in IMAGE_MIME_TYPES:
return "image"
if content_type in AUDIO_MIME_TYPES:
@@ -1186,6 +1208,8 @@ _DOCUMENT_EXTENSION_MIME: Dict[str, str] = {
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"xls": "application/vnd.ms-excel",
"msg": "application/vnd.ms-outlook",
"zip": "application/zip",
"eml": "message/rfc822",
"py": "text/x-python",
"txt": "text/plain",
"html": "text/html",
@@ -1206,12 +1230,13 @@ def _resolve_document_mime_type(file: UploadFile) -> Optional[str]:
documents with ambiguous content types (e.g. `.md` sent as octet-stream) still get a
mime_type accepted by `FileMedia`.
"""
if file.content_type and file.content_type in DOCUMENT_MIME_TYPES:
return file.content_type
content_type = _normalize_document_mime_type(file.content_type)
if content_type and content_type in DOCUMENT_MIME_TYPES:
return content_type
if file.filename and "." in file.filename:
extension = file.filename.rsplit(".", 1)[-1].lower()
return _DOCUMENT_EXTENSION_MIME.get(extension)
return file.content_type
return content_type
def process_document(file: UploadFile, metadata: Optional[Dict[str, Any]] = None) -> Optional[FileMedia]:
@@ -1237,8 +1262,9 @@ def extract_format(file: UploadFile) -> Optional[str]:
return file.filename.split(".")[-1].lower()
# Fallback to the file content_type
if file.content_type:
return file.content_type.strip().split("/")[-1]
content_type = _normalize_document_mime_type(file.content_type)
if content_type:
return content_type.strip().split("/")[-1]
return None
@@ -3,6 +3,8 @@
import json
from unittest.mock import AsyncMock, patch
import pytest
from agno.agent.agent import Agent
from agno.media import File as FileMedia
from agno.media import Image
@@ -199,6 +201,26 @@ class TestAgentFileUploads:
assert docs[0].filename == "contract.pdf"
assert docs[0].metadata == {"category": "legal"}
@pytest.mark.parametrize("content_type", ["application/x-zip", "application/x-zip-compressed"])
def test_upload_zip_mime_alias(self, test_os_client, test_agent: Agent, content_type: str):
with (
patch.object(test_agent, "deep_copy", return_value=test_agent),
patch.object(test_agent, "arun", new_callable=AsyncMock) as mock_arun,
):
mock_arun.return_value = MockRunOutput()
files = [("files", ("archive.zip", b"zip-data", content_type))]
resp = self._post_agent_files(test_os_client, test_agent.id, files)
assert resp.status_code == 200
docs = mock_arun.call_args.kwargs["files"]
assert len(docs) == 1
assert isinstance(docs[0], FileMedia)
assert docs[0].content == b"zip-data"
assert docs[0].filename == "archive.zip"
assert docs[0].format == "zip"
assert docs[0].mime_type == "application/zip"
# ---------------------------------------------------------------------------
# Team endpoint — file uploads
@@ -294,3 +316,23 @@ class TestTeamFileUploads:
assert isinstance(docs[0], FileMedia)
assert docs[0].filename == "report.pdf"
assert docs[0].metadata == {"category": "report"}
@pytest.mark.parametrize("content_type", ["application/x-zip", "application/x-zip-compressed"])
def test_team_upload_zip_mime_alias(self, test_os_client, test_team: Team, content_type: str):
with (
patch.object(test_team, "deep_copy", return_value=test_team),
patch.object(test_team, "arun", new_callable=AsyncMock) as mock_arun,
):
mock_arun.return_value = MockRunOutput()
files = [("files", ("archive.zip", b"zip-data", content_type))]
resp = self._post_team_files(test_os_client, test_team.id, files)
assert resp.status_code == 200
docs = mock_arun.call_args.kwargs["files"]
assert len(docs) == 1
assert isinstance(docs[0], FileMedia)
assert docs[0].content == b"zip-data"
assert docs[0].filename == "archive.zip"
assert docs[0].format == "zip"
assert docs[0].mime_type == "application/zip"
@@ -401,7 +401,7 @@ class TestNonStreamingRoutes:
async def test_mixed_files_categorized_correctly(self):
agent_mock = make_agent_mock()
mock_slack = make_slack_mock(token="xoxb-test")
mock_httpx = make_httpx_mock([b"csv-data", b"img-data", b"zip-data"])
mock_httpx = make_httpx_mock([b"csv-data", b"img-data", b"mystery-data"])
with (
patch("agno.os.interfaces.slack.router.verify_slack_signature", return_value=True),
@@ -417,7 +417,7 @@ class TestNonStreamingRoutes:
[
{"id": "F5", "name": "data.csv", "mimetype": "text/csv"},
{"id": "F6", "name": "pic.jpg", "mimetype": "image/jpeg"},
{"id": "F7", "name": "bundle.zip", "mimetype": "application/zip"},
{"id": "F7", "name": "mystery.xyz", "mimetype": "application/x-mystery"},
]
)
resp = make_signed_request(client, body)
@@ -435,7 +435,7 @@ class TestNonStreamingRoutes:
async def test_non_whitelisted_mime_type_passes_none(self):
agent_mock = make_agent_mock()
mock_slack = make_slack_mock(token="xoxb-test")
mock_httpx = make_httpx_mock(b"zipdata")
mock_httpx = make_httpx_mock(b"mysterydata")
with (
patch("agno.os.interfaces.slack.router.verify_slack_signature", return_value=True),
@@ -447,14 +447,14 @@ class TestNonStreamingRoutes:
from fastapi.testclient import TestClient
client = TestClient(app)
body = slack_event_with_files([{"id": "F1", "name": "archive.zip", "mimetype": "application/zip"}])
body = slack_event_with_files([{"id": "F1", "name": "mystery.xyz", "mimetype": "application/x-mystery"}])
resp = make_signed_request(client, body)
assert resp.status_code == 200
await wait_for_call(agent_mock.arun)
uploaded_file = agent_mock.arun.call_args.kwargs["files"][0]
assert uploaded_file.mime_type is None
assert uploaded_file.content == b"zipdata"
assert uploaded_file.content == b"mysterydata"
@pytest.mark.asyncio
async def test_non_streaming_clears_status_after_response(self):
+32 -1
View File
@@ -121,6 +121,8 @@ DOCUMENT_FORMATS = [
("sheet.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
("sheet.xls", "application/vnd.ms-excel"),
("email.msg", "application/vnd.ms-outlook"),
("archive.zip", "application/zip"),
("email.eml", "message/rfc822"),
("module.py", "text/x-python"),
("readme.txt", "text/plain"),
("page.html", "text/html"),
@@ -131,6 +133,16 @@ DOCUMENT_FORMATS = [
("doc.rtf", "text/rtf"),
]
# Alternate spellings clients send for formats already in DOCUMENT_FORMATS:
# (filename, MIME the client sends, canonical MIME it must resolve to).
MIME_TYPE_ALIASES = [
("archive.zip", "application/x-zip", "application/zip"),
("archive.zip", "application/x-zip-compressed", "application/zip"),
("feed.xml", "application/xml", "text/xml"),
("doc.rtf", "application/rtf", "text/rtf"),
("script.js", "application/javascript", "text/javascript"),
]
class TestClassifyUploadFile:
"""Tests for classify_upload_file, including the extension fallback for ambiguous content types."""
@@ -151,6 +163,10 @@ class TestClassifyUploadFile:
def test_all_document_content_types_route_to_document(self, filename, content_type):
assert classify_upload_file(_make_upload_file(filename, content_type)) == "document"
@pytest.mark.parametrize("filename, content_type, _canonical", MIME_TYPE_ALIASES)
def test_mime_aliases_route_to_document(self, filename, content_type, _canonical):
assert classify_upload_file(_make_upload_file(filename, content_type)) == "document"
@pytest.mark.parametrize("filename, _content_type", DOCUMENT_FORMATS)
@pytest.mark.parametrize("ambiguous", ["application/octet-stream", "", None])
def test_documents_fall_back_to_extension(self, filename, _content_type, ambiguous):
@@ -162,7 +178,6 @@ class TestClassifyUploadFile:
def test_unsupported_type_returns_none(self):
"""Genuinely unsupported files must still be rejected (router raises 400)."""
assert classify_upload_file(_make_upload_file("archive.zip", "application/zip")) is None
assert classify_upload_file(_make_upload_file("mystery.xyz", "application/octet-stream")) is None
assert classify_upload_file(_make_upload_file("noext", "application/octet-stream")) is None
@@ -189,6 +204,22 @@ class TestProcessDocument:
assert result.mime_type == content_type
assert result.format == filename.rsplit(".", 1)[-1]
@pytest.mark.parametrize("filename, content_type, canonical", MIME_TYPE_ALIASES)
def test_mime_aliases_are_canonicalized(self, filename, content_type, canonical):
result = process_document(_make_upload_file(filename, content_type, b"data"))
assert result is not None
assert result.mime_type == canonical
assert result.format == filename.rsplit(".", 1)[-1]
@pytest.mark.parametrize("filename, content_type, canonical", MIME_TYPE_ALIASES)
def test_mime_aliases_without_extension_use_canonical_format(self, filename, content_type, canonical):
"""Without an extension the format falls back to the content type, which must be canonical."""
result = process_document(_make_upload_file("attachment", content_type, b"data"))
assert result is not None
assert result.format == canonical.split("/")[-1]
def test_empty_file_raises(self):
from fastapi import HTTPException