From 393cee470f3ec0c20fa8a72079f23ac626ee3a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Martins?= Date: Fri, 4 Sep 2026 09:44:21 +0100 Subject: [PATCH] feat: support .zip and .eml file uploads in AgentOS (#9193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Claude Sonnet 5 Co-authored-by: Kaustubh Co-authored-by: Ashpreet Co-authored-by: Ashpreet Co-authored-by: Sannya Singal <32308435+sannya-singal@users.noreply.github.com> Co-authored-by: Harsh <74086017+harshsinha03@users.noreply.github.com> Co-authored-by: Harsh Sinha --- .../05_agent_os/04_run_lifecycle/README.md | 5 +- .../05_agent_os/04_run_lifecycle/TEST_LOG.md | 20 ++++ .../04_run_lifecycle/unpack_archives.py | 100 ++++++++++++++++++ libs/agno/agno/media/media.py | 2 + libs/agno/agno/os/utils.py | 38 +++++-- .../tests/integration/os/test_file_uploads.py | 42 ++++++++ .../unit/os/routers/test_slack_router.py | 10 +- libs/agno/tests/unit/os/test_utils.py | 33 +++++- 8 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 cookbook/05_agent_os/04_run_lifecycle/unpack_archives.py diff --git a/cookbook/05_agent_os/04_run_lifecycle/README.md b/cookbook/05_agent_os/04_run_lifecycle/README.md index 12a62dd2b2..4e06b677aa 100644 --- a/cookbook/05_agent_os/04_run_lifecycle/README.md +++ b/cookbook/05_agent_os/04_run_lifecycle/README.md @@ -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 diff --git a/cookbook/05_agent_os/04_run_lifecycle/TEST_LOG.md b/cookbook/05_agent_os/04_run_lifecycle/TEST_LOG.md index 86197bf21d..423fd6fc22 100644 --- a/cookbook/05_agent_os/04_run_lifecycle/TEST_LOG.md +++ b/cookbook/05_agent_os/04_run_lifecycle/TEST_LOG.md @@ -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`: diff --git a/cookbook/05_agent_os/04_run_lifecycle/unpack_archives.py b/cookbook/05_agent_os/04_run_lifecycle/unpack_archives.py new file mode 100644 index 0000000000..ad5f53dcc5 --- /dev/null +++ b/cookbook/05_agent_os/04_run_lifecycle/unpack_archives.py @@ -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/._" + # 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) diff --git a/libs/agno/agno/media/media.py b/libs/agno/agno/media/media.py index 0ac7c09f8e..87f424aaa5 100644 --- a/libs/agno/agno/media/media.py +++ b/libs/agno/agno/media/media.py @@ -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", diff --git a/libs/agno/agno/os/utils.py b/libs/agno/agno/os/utils.py index d37307457d..9f1648ed23 100644 --- a/libs/agno/agno/os/utils.py +++ b/libs/agno/agno/os/utils.py @@ -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 diff --git a/libs/agno/tests/integration/os/test_file_uploads.py b/libs/agno/tests/integration/os/test_file_uploads.py index 88dd8b9514..7c7dd1e732 100644 --- a/libs/agno/tests/integration/os/test_file_uploads.py +++ b/libs/agno/tests/integration/os/test_file_uploads.py @@ -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" diff --git a/libs/agno/tests/unit/os/routers/test_slack_router.py b/libs/agno/tests/unit/os/routers/test_slack_router.py index eafcabdf8f..aef0047873 100644 --- a/libs/agno/tests/unit/os/routers/test_slack_router.py +++ b/libs/agno/tests/unit/os/routers/test_slack_router.py @@ -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): diff --git a/libs/agno/tests/unit/os/test_utils.py b/libs/agno/tests/unit/os/test_utils.py index 4a086c7a05..0b3684fc69 100644 --- a/libs/agno/tests/unit/os/test_utils.py +++ b/libs/agno/tests/unit/os/test_utils.py @@ -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