"""Resolve file_id references in content blocks to inline content. Scans conversation items for content blocks that reference uploaded files via ``file_id`` and replaces them with inline base64 content. This runs as a pre-processing step before prompt construction so the prompt builder remains pure (no I/O). See ``designs/MULTIMODAL_INFERENCE.md`` for the full design. """ from __future__ import annotations import base64 import copy import logging from typing import Any from omnigent.entities import ConversationItem, MessageData from omnigent.stores import ArtifactStore, FileStore _logger = logging.getLogger(__name__) # Extensions that Python's mimetypes module doesn't always know, # depending on the platform and Python version. Used as a fallback # when the stored content_type is missing or generic. LLM providers # (OpenAI) reject application/octet-stream for text files, so any # text-like format needs a proper MIME type. # MIME types the OpenAI Responses API accepts on # ``input_file.file_data`` data URIs. Text-like types outside this # allowlist (e.g. ``text/yaml``, ``text/x-rust``, ``text/typescript``) # get rejected at the provider with a 400 ``invalid_value`` referencing # ``input[N].content[M].file_data``. The accompanying ``filename`` # already tells the model the original extension, so collapsing to # ``text/plain`` loses no signal — see :func:`_safe_file_data_mime`. _FILE_DATA_PASSTHROUGH_MIMES: frozenset[str] = frozenset( { "application/pdf", "text/plain", "text/markdown", "text/html", "text/csv", "application/json", "text/javascript", "application/javascript", "text/x-python", } ) _EXTRA_MIME_TYPES: dict[str, str] = { # Markup / config ".md": "text/markdown", ".markdown": "text/markdown", ".yaml": "text/yaml", ".yml": "text/yaml", ".toml": "text/plain", ".jsonl": "application/jsonl", ".ndjson": "application/x-ndjson", ".proto": "text/plain", ".graphql": "text/plain", ".gql": "text/plain", # Languages mimetypes misses ".rs": "text/x-rust", ".go": "text/x-go", ".ts": "text/typescript", ".tsx": "text/typescript", ".jsx": "text/javascript", ".swift": "text/x-swift", ".kt": "text/x-kotlin", ".scala": "text/x-scala", ".r": "text/x-r", ".jl": "text/x-julia", ".lua": "text/x-lua", ".ex": "text/x-elixir", ".exs": "text/x-elixir", ".erl": "text/x-erlang", ".hs": "text/x-haskell", ".clj": "text/x-clojure", ".dart": "text/x-dart", ".vue": "text/plain", ".svelte": "text/plain", # Infra / build ".tf": "text/plain", ".hcl": "text/plain", ".dockerfile": "text/plain", ".gradle": "text/plain", ".ipynb": "application/x-ipynb+json", # Dotfiles ".env": "text/plain", ".lock": "text/plain", } # ── Attachment upload limits ────────────────────────────────────────── # Uploaded attachments are inlined into the model context as base64 (see # :func:`resolve_content_references`) and re-sent every turn, so sizes are # bounded well under the model's context budget and the provider's API # limits — Anthropic accepts images up to ~5 MB, PDFs up to ~32 MB / 100 # pages, and ~32 MB per request total. The per-type caps below keep a # single attachment usable across a multi-turn conversation; the global # ceiling backstops the total request size after base64 inflation (~1.33x). # Mirrored client-side in web/src/lib/attachments.ts — keep in sync. MAX_IMAGE_UPLOAD_BYTES: int = 5 * 1024 * 1024 MAX_PDF_UPLOAD_BYTES: int = 20 * 1024 * 1024 MAX_TEXT_UPLOAD_BYTES: int = 10 * 1024 * 1024 MAX_ATTACHMENT_UPLOAD_BYTES: int = 25 * 1024 * 1024 # Copy-at-spawn limits (see the ``files:copy`` endpoint). A parent forwarding # files to a subagent copies them through the server, which reads each source # blob to re-store it under the child. Bounding the count and the summed # ``StoredFile.bytes`` — checked against metadata BEFORE any blob is read — # stops a single send from spiking shared-server memory. Defaults are the # floor; a deployment can raise or lower them via ``server_config`` (see # :func:`omnigent.server.server_config.copy_file_count_limit` and # :func:`~omnigent.server.server_config.copy_total_bytes_limit`). For # reference, OpenAI caps code-interpreter at 20 files, Anthropic Files at # 500 MB/file. MAX_COPY_FILES: int = 20 MAX_COPY_TOTAL_BYTES: int = 256 * 1024 * 1024 # ``application/*`` MIME types we treat as text-like. The rest of the # text-like surface is ``text/*`` (covered by the prefix check) — these # are the text-bearing ``application/*`` types code/data files resolve to. _TEXT_LIKE_APPLICATION_MIMES: frozenset[str] = frozenset( { "application/json", "application/javascript", "application/jsonl", "application/x-ndjson", "application/x-ipynb+json", } ) def attachment_upload_limit(content_type: str) -> int | None: """ Max upload size (bytes) for *content_type*, or ``None`` if the type is not an allowed attachment. Allowed: images, PDF, and text-like files (``text/*`` plus a few text-bearing ``application/*`` types — JSON, JS, JSONL, notebooks). Office / binary formats (pptx, docx, xlsx, zip, …) return ``None`` and are rejected at upload: the model can't read their raw bytes (Anthropic's base64 ``document`` source accepts only PDF), so inlining them only produces garbled UTF-8 or — for large files — an oversized, context-blowing request. Callers reject ``None`` with HTTP 415. :param content_type: The resolved MIME type, e.g. ``"image/png"``. Use :func:`_resolve_content_type` to derive it from the upload's declared type + filename first. :returns: The per-type byte limit (still subject to :data:`MAX_ATTACHMENT_UPLOAD_BYTES`), or ``None`` when the type is not an allowed attachment. """ if content_type.startswith("image/"): return MAX_IMAGE_UPLOAD_BYTES if content_type == "application/pdf": return MAX_PDF_UPLOAD_BYTES if content_type.startswith("text/") or content_type in _TEXT_LIKE_APPLICATION_MIMES: return MAX_TEXT_UPLOAD_BYTES return None # Extensions accepted as text/code attachments even when the upload's # declared MIME mislabels them as binary — e.g. a ``.csv`` tagged # ``application/vnd.ms-excel`` on Windows, or a ``.ts`` tagged # ``video/mp2t``. Mirrors TEXT_CODE_EXTENSIONS in # web/src/lib/attachments.ts — keep in sync. _TEXT_CODE_EXTENSIONS: frozenset[str] = frozenset( { ".txt", ".log", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl", ".ndjson", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env", ".lock", ".proto", ".graphql", ".gql", ".html", ".htm", ".xml", ".css", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".py", ".rb", ".go", ".rs", ".java", ".kt", ".scala", ".swift", ".c", ".h", ".cc", ".cpp", ".hpp", ".cs", ".php", ".pl", ".r", ".jl", ".lua", ".ex", ".exs", ".erl", ".hs", ".clj", ".dart", ".vue", ".svelte", ".sh", ".bash", ".zsh", ".fish", ".sql", ".tf", ".hcl", ".gradle", ".dockerfile", ".ipynb", } ) def attachment_text_type_for_extension(filename: str | None) -> str | None: """ Resolve a text-like MIME for *filename* by extension, or ``None``. Used as a fallback when the upload's declared MIME mislabels a text/code file as binary (e.g. a ``.csv`` reported as ``application/vnd.ms-excel``): only extensions in :data:`_TEXT_CODE_EXTENSIONS` are honored, so a real binary (``.xls``, ``.pptx``) is never re-admitted. Mirrors the web client's extension allowlist so the two agree on what's attachable. :param filename: The original filename, e.g. ``"data.csv"``. :returns: A concrete text-like MIME (e.g. ``"text/csv"``), or ``None`` when the extension is not a recognised text/code type. """ import mimetypes as _mt from pathlib import PurePath if not filename: return None suffix = PurePath(filename).suffix.lower() if suffix not in _TEXT_CODE_EXTENSIONS: return None mapped = _EXTRA_MIME_TYPES.get(suffix) if mapped: return mapped guessed = _mt.guess_type(filename)[0] if guessed and (guessed.startswith("text/") or guessed in _TEXT_LIKE_APPLICATION_MIMES): return guessed return "text/plain" def resolve_content_references( items: list[ConversationItem], file_store: FileStore, artifact_store: ArtifactStore, cache: dict[str, str] | None = None, *, session_id: str | None = None, ) -> list[ConversationItem]: """ Resolve ``file_id`` references in content blocks to inline content. Returns **copies** of items whose content was modified. Items without ``file_id`` references are returned as-is (no copy). The originals in the conversation store remain unchanged. Resolves ``file_id`` on **any** block type (``input_image``, ``input_file``, or future types like ``input_audio``). External URLs (``image_url``, ``file_url``) are never fetched — they pass through unchanged (SSRF protection). :param items: Persisted conversation items in chronological order, e.g. from ``conversation_store.fetch_all()``. :param file_store: Store for looking up file metadata (``content_type``, ``filename``). :param artifact_store: Store for fetching file binary content. :param cache: Optional per-task cache mapping ``file_id`` to its base64-encoded content. Avoids re-fetching and re-encoding the same file across agent loop iterations. Pass ``None`` to disable caching. :param session_id: Optional owning session id used to verify session-scoped file ownership, e.g. ``"conv_abc123"``. :returns: A list of conversation items with all ``file_id`` references replaced by inline base64 content. :raises ValueError: If a referenced ``file_id`` does not exist in the file store. :raises KeyError: If a referenced ``file_id`` exists in the file store but its binary content is missing from the artifact store. """ result: list[ConversationItem] = [] for item in items: if item.type == "message" and isinstance(item.data, MessageData): resolved_content = _resolve_message_content( item.data.content, file_store, artifact_store, cache, session_id=session_id, ) if resolved_content is item.data.content: # No file_id references found — reuse original. result.append(item) else: # Content was modified — deep-copy and replace. item_copy = copy.deepcopy(item) assert isinstance(item_copy.data, MessageData) item_copy.data.content = resolved_content result.append(item_copy) else: result.append(item) return result def _resolve_message_content( content: list[dict[str, Any]], file_store: FileStore, artifact_store: ArtifactStore, cache: dict[str, str] | None = None, *, session_id: str | None = None, ) -> list[dict[str, Any]]: """ Resolve ``file_id`` references in a list of content blocks. Returns the **original list** if no blocks contain ``file_id`` (caller uses identity check to detect changes). Returns a **new list** with resolved blocks if any ``file_id`` was found. :param content: Content block dicts from ``MessageData.content``. :param file_store: Store for file metadata lookups. :param artifact_store: Store for binary content fetches. :param cache: Optional per-task base64 cache (see :func:`resolve_content_references`). :param session_id: Optional owning session id used to verify session-scoped file ownership, e.g. ``"conv_abc123"``. :returns: The original list (unchanged) or a new list with ``file_id`` references resolved to inline content. """ resolved: list[dict[str, Any]] = [] changed = False for block in content: if "file_id" in block: resolved.append( _resolve_file_id_block( block, file_store, artifact_store, cache, session_id=session_id, ) ) changed = True else: resolved.append(block) # Return original list when nothing changed so caller can use # identity check (``is``) to skip unnecessary deep-copies. return resolved if changed else content def _session_id_from_block(block: dict[str, Any]) -> str | None: """ Extract optional session ownership from a content block. :param block: Content block dict, e.g. ``{"file_id": "file_abc123", "session_id": "conv_abc123"}``. :returns: Session id if present, otherwise ``None``. """ for key in ("session_id", "conversation_id"): value = block.get(key) if isinstance(value, str) and value: return value return None def _resolve_file_id_block( block: dict[str, Any], file_store: FileStore, artifact_store: ArtifactStore, cache: dict[str, str] | None = None, *, session_id: str | None = None, ) -> dict[str, Any]: """ Resolve a single content block's ``file_id`` to inline content. For ``input_image`` blocks: replaces ``file_id`` with ``image_url`` containing a ``data:`` URI. For all other block types (``input_file``, future types): replaces ``file_id`` with ``file_data`` containing a ``data:`` URI (e.g. ``"data:application/pdf;base64,..."``). Provider adapters parse the URI to extract the media type and payload. :param block: A content block dict containing ``file_id``, e.g. ``{"type": "input_image", "file_id": "file_abc123"}``. :param file_store: Store for file metadata lookups. :param artifact_store: Store for binary content fetches. :param cache: Optional per-task base64 cache (see :func:`resolve_content_references`). :param session_id: Optional owning session id used to verify session-scoped file ownership, e.g. ``"conv_abc123"``. :returns: A new dict with ``file_id`` replaced by inline content. All other fields are preserved. :raises ValueError: If ``file_id`` is not found in the file store — the file was deleted between request validation and agent loop execution. """ file_id = block["file_id"] owner_session_id = session_id or _session_id_from_block(block) file_meta = file_store.get(file_id) if file_meta is None or ( file_meta.session_id is not None and file_meta.session_id != owner_session_id ): raise ValueError( f"Referenced file '{file_id}' no longer exists — " f"it may have been deleted after the request was accepted" ) # Use cached base64 if available; otherwise fetch, encode, and cache. if cache is not None and file_id in cache: encoded = cache[file_id] else: content_bytes = artifact_store.get(file_id) encoded = base64.b64encode(content_bytes).decode("ascii") if cache is not None: cache[file_id] = encoded # Copy all fields except file_id. resolved: dict[str, Any] = {k: v for k, v in block.items() if k != "file_id"} content_type = _resolve_content_type(file_meta.content_type, file_meta.filename) block_type = block.get("type") if block_type == "input_image": resolved["image_url"] = f"data:{content_type};base64,{encoded}" else: # input_file and any future type: inline as file_data. # Uses a data: URI so providers (OpenAI, etc.) can parse # the media type alongside the payload. The Responses API # rejects most non-standard text MIMEs here, so coerce # to a safe type — see :func:`_safe_file_data_mime`. safe_type = _safe_file_data_mime(content_type) resolved["file_data"] = f"data:{safe_type};base64,{encoded}" return resolved def _safe_file_data_mime(content_type: str) -> str: """ Coerce *content_type* to one accepted by the OpenAI Responses API on ``input_file.file_data``. The Responses API restricts ``file_data`` MIMEs to a small allowlist (see :data:`_FILE_DATA_PASSTHROUGH_MIMES`). Anything else text-like that we'd normally hand back from :func:`_resolve_content_type` (``text/yaml``, ``text/x-rust``, ``text/typescript`` and friends, plus JSONL-ish ``application/x-*`` variants) collapses to ``text/plain``. The base64 payload is unchanged — only the MIME hint shifts — and the block's ``filename`` field carries the original extension for the model to interpret. Non-text types we don't recognise (``image/*``, ``audio/*``, third-party ``application/*``) pass through unchanged: we have no fixed list there and downgrading them would mislead the provider. :param content_type: The precise MIME from :func:`_resolve_content_type`, e.g. ``"text/yaml"``. :returns: Either *content_type* unchanged (when on the passthrough list or non-text) or ``"text/plain"`` (for text-like MIMEs the Responses API rejects). """ if content_type in _FILE_DATA_PASSTHROUGH_MIMES: return content_type if content_type.startswith("text/"): return "text/plain" if content_type in { "application/jsonl", "application/x-ndjson", "application/x-ipynb+json", }: return "text/plain" return content_type def _resolve_content_type( stored_type: str | None, filename: str | None, ) -> str: """ Determine the MIME type for a file, with fallbacks. Priority: stored content_type (unless it's the generic ``application/octet-stream``) → ``mimetypes.guess_type`` from filename → ``_EXTRA_MIME_TYPES`` lookup → ``text/plain`` for text-like extensions → ``application/octet-stream``. Some LLM providers (OpenAI) reject ``application/octet-stream`` for text files, so we try hard to resolve a specific type. :param stored_type: The content_type from file metadata, or ``None``. :param filename: The original filename, e.g. ``"report.md"``. :returns: A MIME type string. """ import mimetypes as _mt from pathlib import PurePath # Use stored type if it's specific (not the generic fallback). if stored_type and stored_type != "application/octet-stream": return stored_type if filename: suffix = PurePath(filename).suffix.lower() # Our map takes priority over mimetypes — the stdlib has # wrong mappings for some code extensions (e.g. .ts → # video/mp2t, .rs → application/rls-services+xml). if suffix in _EXTRA_MIME_TYPES: return _EXTRA_MIME_TYPES[suffix] guessed = _mt.guess_type(filename)[0] if guessed and guessed != "application/octet-stream": return guessed # Text-like extensions default to text/plain rather than # octet-stream, which providers are more likely to accept. if suffix in {".txt", ".log", ".cfg", ".ini", ".env"}: return "text/plain" return stored_type or "application/octet-stream"