Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/uipath_langchain/agent/exceptions/attachments.py
Original file line number Diff line number Diff line change
@@ -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__
13 changes: 8 additions & 5 deletions src/uipath_langchain/agent/multimodal/invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions src/uipath_langchain/agent/react/llm_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
23 changes: 22 additions & 1 deletion tests/agent/multimodal/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="")

Expand Down
45 changes: 45 additions & 0 deletions tests/agent/test_exception_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Loading