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
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-langchain"
version = "0.14.0"
version = "0.14.1"
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand All @@ -9,6 +9,7 @@ dependencies = [
"uipath-core>=0.5.28, <0.6.0",
"uipath-platform>=0.2.0, <0.3.0",
"uipath-runtime>=0.12.1, <0.13.0",
"uipath-llm-client>=1.16.2, <1.17.0",
"langgraph>=1.1.8, <2.0.0",
"langchain-core>=1.2.27, <2.0.0",
"langgraph-checkpoint-sqlite>=3.0.3, <4.0.0",
Expand Down
40 changes: 40 additions & 0 deletions src/uipath_langchain/agent/exceptions/attachments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Map a normalized unsupported-MIME rejection to a USER agent error."""

from collections.abc import Iterator

from uipath.llm_client import UiPathError, UiPathLLMErrorCode
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain.agent.exceptions.exceptions import (
AgentRuntimeError,
AgentRuntimeErrorCode,
)


def _iter_error_chain(exc: BaseException) -> Iterator[BaseException]:
seen: set[int] = set()
current: BaseException | None = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
yield current
current = current.__cause__ or current.__context__


def raise_for_unsupported_attachment(exc: BaseException) -> None:
"""Raise a USER ``FILE_ERROR`` if the error chain carries a normalized
``UNSUPPORTED_MIME_TYPE``; no-op otherwise."""
for err in _iter_error_chain(exc):
if (
isinstance(err, UiPathError)
and err.error_code == UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE
):
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: {err.detail}" if err.detail else "")
),
category=UiPathErrorCategory.USER,
) from exc
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
5 changes: 3 additions & 2 deletions src/uipath_langchain/agent/react/llm_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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 @@ -125,9 +126,9 @@ async def llm_node(state: StateT):
# New LLM clients surface provider HTTP errors as a normalized UiPathAPIError directly.
raise_for_provider_http_error(e)
except Exception as e:
# Legacy in-repo clients (use_new_llm_clients=False) raise raw provider SDK exceptions.
# Normalize via as_uipath_error and apply the same mapping when the error is HTTP-shaped; non-HTTP errors propagate.
# Legacy clients raise raw provider exceptions; normalize before mapping.
uipath_error = as_uipath_error(e)
raise_for_unsupported_attachment(uipath_error)
if isinstance(uipath_error, UiPathAPIError):
raise_for_provider_http_error(uipath_error)
raise
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
58 changes: 58 additions & 0 deletions tests/agent/test_exception_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,61 @@ 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 (normalized MIME code -> USER)."""

def test_translates_direct_unsupported_mime_error(self) -> None:
from uipath.llm_client import UiPathError, UiPathLLMErrorCode

from uipath_langchain.agent.exceptions.attachments import (
raise_for_unsupported_attachment,
)

exc = UiPathError(
error_code=UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE,
detail="Unsupported MIME type: application/octet-stream.",
)

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_code_in_cause_chain(self) -> None:
from uipath.llm_client import UiPathError, UiPathLLMErrorCode

from uipath_langchain.agent.exceptions.attachments import (
raise_for_unsupported_attachment,
)

root = UiPathError(
error_code=UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE,
detail="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_or_unnormalized_exception(self) -> None:
from uipath.llm_client import UiPathError

from uipath_langchain.agent.exceptions.attachments import (
raise_for_unsupported_attachment,
)

raise_for_unsupported_attachment(
UiPathError(error_code="SOME_OTHER_CODE", detail="unrelated")
)
raise_for_unsupported_attachment(
ValueError("Unsupported MIME type: application/x-foo")
)
raise_for_unsupported_attachment(RuntimeError("network down"))
10 changes: 6 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading