From 11ceff926c1c906ad21d825f2fb9f8a146ec407f Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Thu, 25 Jun 2026 19:06:55 +0300 Subject: [PATCH] fix(errors): categorize provider unsupported-MIME rejection as User MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format support for non-image attachments is delegated to the LLM/provider (#842) — do not reintroduce a client-side MIME allow-list. Instead, translate the provider's own 'Unsupported MIME type' ValueError (raised by langchain_aws Bedrock Converse during request conversion) into a User-categorized AgentRuntimeError(FILE_ERROR) at the model-invocation boundary in llm_node, matched across the exception chain. build_file_content_blocks_for keeps the #842 pass-through behavior (any non-image MIME -> file block). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agent/exceptions/attachments.py | 55 +++++++++++++++++++ .../agent/multimodal/invoke.py | 13 +++-- src/uipath_langchain/agent/react/llm_node.py | 8 ++- tests/agent/multimodal/test_utils.py | 23 +++++++- tests/agent/test_exception_helpers.py | 45 +++++++++++++++ 5 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 src/uipath_langchain/agent/exceptions/attachments.py diff --git a/src/uipath_langchain/agent/exceptions/attachments.py b/src/uipath_langchain/agent/exceptions/attachments.py new file mode 100644 index 000000000..333b694b6 --- /dev/null +++ b/src/uipath_langchain/agent/exceptions/attachments.py @@ -0,0 +1,55 @@ +"""Typed errors for unsupported file attachments. + +Format support for non-image attachments is delegated to the LLM/provider (#842): +we do not gate on a MIME allow-list. When a provider cannot read a file's type it +rejects it during request conversion with a bare ``ValueError`` (e.g. AWS Bedrock +Converse raises ``"Unsupported MIME type: ..."``). The file type is the user's +choice, so that provider verdict is translated into a ``USER`` error at the +model-invocation boundary rather than surfacing as an opaque ``Unknown``. +""" + +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_langchain.agent.exceptions.exceptions import ( + AgentRuntimeError, + AgentRuntimeErrorCode, +) + +# Marker emitted by provider request-conversion code (langchain_aws Bedrock +# Converse) when an attachment's MIME type is not accepted. Matched across the +# exception chain — we react to the provider's verdict, we do not maintain our +# own list of supported types. +_UNSUPPORTED_MIME_MARKER = "Unsupported MIME type" + + +def raise_for_unsupported_attachment(exc: BaseException) -> None: + """Translate a provider 'unsupported MIME type' rejection into a USER error. + + Walks the exception's ``__cause__``/``__context__`` chain for the provider + marker. If found, raises a USER-categorized :class:`AgentRuntimeError` chained + from the original. No-op otherwise, so callers fall through to their normal + error handling. + + Args: + exc: The exception raised by the model invocation. + + Raises: + AgentRuntimeError: USER-categorized ``FILE_ERROR`` when the chain carries + an unsupported-MIME-type provider rejection. + """ + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, ValueError) and _UNSUPPORTED_MIME_MARKER in str(current): + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.FILE_ERROR, + title="Unsupported file attachment format.", + detail=( + "An attachment has a file type the model does not support. " + "Remove the attachment or convert it to a supported format. " + f"Provider detail: {current}" + ), + category=UiPathErrorCategory.USER, + ) from exc + current = current.__cause__ or current.__context__ diff --git a/src/uipath_langchain/agent/multimodal/invoke.py b/src/uipath_langchain/agent/multimodal/invoke.py index 297356878..5c486898b 100644 --- a/src/uipath_langchain/agent/multimodal/invoke.py +++ b/src/uipath_langchain/agent/multimodal/invoke.py @@ -31,10 +31,12 @@ async def build_file_content_blocks_for( ) -> list[DataContentBlock]: """Build LangChain content blocks for a single file attachment. - Images become image blocks, TIFFs are split into per-page PNG blocks, - and every other MIME type — PDF, text, office documents, and any - arbitrary binary — is wrapped in a generic file block. Provider - compatibility for non-image formats is delegated to the LLM. + Images become image blocks, TIFFs are split into per-page PNG blocks, and + every other MIME type is wrapped in a generic file block. Format support for + non-image types is delegated to the LLM/provider (see #842): we do not gate + on a MIME allow-list here. A type the provider cannot read surfaces later as + a provider error at the model-invocation boundary, where it is translated + into a USER error. Args: file_info: File URL, name, and MIME type. @@ -53,6 +55,8 @@ async def build_file_content_blocks_for( except ValueError as exc: raise ValueError(f"File '{file_info.name}': {exc}") from exc + mime_type = file_info.mime_type or "application/octet-stream" + try: base64_file = await download_file_base64(file_info.url, max_size=max_size) except ValueError as exc: @@ -61,7 +65,6 @@ async def build_file_content_blocks_for( if is_image(file_info.mime_type): return [create_image_block(base64=base64_file, mime_type=file_info.mime_type)] - mime_type = file_info.mime_type or "application/octet-stream" return [ create_file_block( base64=base64_file, diff --git a/src/uipath_langchain/agent/react/llm_node.py b/src/uipath_langchain/agent/react/llm_node.py index 32aecc788..bf7144ef3 100644 --- a/src/uipath_langchain/agent/react/llm_node.py +++ b/src/uipath_langchain/agent/react/llm_node.py @@ -16,6 +16,7 @@ from uipath_langchain.chat.handlers import get_payload_handler from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode +from ..exceptions.attachments import raise_for_unsupported_attachment from ..exceptions.licensing import raise_for_provider_http_error from ..messages.message_utils import replace_tool_calls from ..tools.static_args import StaticArgsHandler @@ -121,8 +122,11 @@ async def llm_node(state: StateT): response = await llm.ainvoke(messages) except Exception as e: # LLM errors arrive as provider-specific exceptions (OpenAI, Bedrock, - # Vertex). Convert to a structured AgentRuntimeError with the HTTP - # status code so upstream handlers can categorise (e.g. 403 → licensing). + # Vertex). A provider rejecting an attachment's MIME type is the user's + # file-type choice → USER (format support is delegated to the provider, + # #842). HTTP errors carry a status for upstream categorisation + # (e.g. 403 → licensing). + raise_for_unsupported_attachment(e) raise_for_provider_http_error(e) raise if not isinstance(response, AIMessage): diff --git a/tests/agent/multimodal/test_utils.py b/tests/agent/multimodal/test_utils.py index b79f3309e..b8174f2d3 100644 --- a/tests/agent/multimodal/test_utils.py +++ b/tests/agent/multimodal/test_utils.py @@ -231,10 +231,31 @@ async def test_arbitrary_mime_type_returns_file_block( assert blocks[0]["type"] == "file" assert blocks[0]["mime_type"] == "text/csv" + async def test_unsupported_mime_type_returns_file_block( + self, httpx_mock: HTTPXMock + ) -> None: + """Format support is delegated to the LLM/provider (#842): an arbitrary + MIME type is wrapped in a file block here, not rejected. A provider that + cannot read it raises at the model-invocation boundary, where it is + translated into a USER error.""" + content = b"\x00\x01\x02" + httpx_mock.add_response(url=FILE_URL, content=content) + file_info = FileInfo( + url=FILE_URL, name="blob.bin", mime_type="application/octet-stream" + ) + + blocks = await build_file_content_blocks_for(file_info) + + assert len(blocks) == 1 + assert blocks[0]["type"] == "file" + assert blocks[0]["mime_type"] == "application/octet-stream" + async def test_empty_mime_type_defaults_to_octet_stream( self, httpx_mock: HTTPXMock ) -> None: - content = b"raw bytes" + """An attachment with no MIME type defaults to octet-stream and is passed + through as a file block (delegated to the LLM).""" + content = b"data" httpx_mock.add_response(url=FILE_URL, content=content) file_info = FileInfo(url=FILE_URL, name="blob.bin", mime_type="") diff --git a/tests/agent/test_exception_helpers.py b/tests/agent/test_exception_helpers.py index 624ecb940..026fa4bf1 100644 --- a/tests/agent/test_exception_helpers.py +++ b/tests/agent/test_exception_helpers.py @@ -138,3 +138,48 @@ def test_original_exception_chained(self) -> None: with pytest.raises(AgentRuntimeError) as exc_info: raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T") assert exc_info.value.__cause__ is err + + +class TestRaiseForUnsupportedAttachment: + """Tests for raise_for_unsupported_attachment (provider MIME-verdict -> USER).""" + + def test_translates_direct_unsupported_mime_valueerror(self) -> None: + from uipath_langchain.agent.exceptions.attachments import ( + raise_for_unsupported_attachment, + ) + + exc = ValueError( + "Unsupported MIME type: application/octet-stream. Please refer to the " + "Bedrock Converse API documentation for supported formats." + ) + + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_unsupported_attachment(exc) + + assert exc_info.value.error_info.category == UiPathErrorCategory.USER + assert exc_info.value.error_info.code == AgentRuntimeError.full_code( + AgentRuntimeErrorCode.FILE_ERROR + ) + + def test_translates_marker_in_cause_chain(self) -> None: + from uipath_langchain.agent.exceptions.attachments import ( + raise_for_unsupported_attachment, + ) + + root = ValueError("Unsupported MIME type: application/x-foo") + wrapper = RuntimeError("model invocation failed") + wrapper.__cause__ = root + + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_unsupported_attachment(wrapper) + + assert exc_info.value.error_info.category == UiPathErrorCategory.USER + + def test_noop_for_unrelated_exception(self) -> None: + from uipath_langchain.agent.exceptions.attachments import ( + raise_for_unsupported_attachment, + ) + + # No marker in the chain -> returns without raising, caller falls through. + raise_for_unsupported_attachment(ValueError("some other failure")) + raise_for_unsupported_attachment(RuntimeError("network down"))