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
2 changes: 1 addition & 1 deletion .claude/skills/develop-agent-module/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Use the structured error types in `exceptions/` — never raise raw `Exception`,
- **Runtime errors** (during execution): `AgentRuntimeError(code=AgentRuntimeErrorCode.X, title=..., detail=..., category=...)`
- **Startup errors** (during init): `AgentStartupError(code=AgentStartupErrorCode.X, title=..., detail=..., category=...)`
- **HTTP errors from platform calls**: catch `EnrichedException`, map via `raise_for_enriched()` in `exceptions/helpers.py`
- **LLM provider errors**: handled by `raise_for_provider_http_error()` in `exceptions/licensing.py`
- **LLM provider errors**: handled by `raise_for_provider_http_error()` in `exceptions/llm.py`
- Always chain exceptions: `raise AgentRuntimeError(...) from e`

## Testing
Expand Down
1 change: 1 addition & 0 deletions src/uipath_langchain/agent/exceptions/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class AgentRuntimeErrorCode(str, Enum):
HTTP_ERROR = "HTTP_ERROR"
LICENSE_NOT_AVAILABLE = "LICENSE_NOT_AVAILABLE"
LLM_PROVIDER_FORBIDDEN = "LLM_PROVIDER_FORBIDDEN"
LLM_PROVIDER_BAD_REQUEST = "LLM_PROVIDER_BAD_REQUEST"

# Routing
ROUTING_ERROR = "ROUTING_ERROR"
Expand Down
59 changes: 51 additions & 8 deletions src/uipath_langchain/agent/exceptions/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@
_LICENSE_ERROR_CODE = 10000
_LICENSE_TITLE = "license not available"

# A canned, provider-free replacement for the useless HTTP reason phrase. The
# relayed provider message is deliberately NOT read out of the body (PC-5002):
# it may carry customer PII, and it is already recorded on the LLM call span,
# which is tenant-scoped. It has to stand on its own -- USER is not in
# _SHOULD_WRAP_CATEGORIES, so nothing else is prepended to it.
_BAD_REQUEST_DETAIL = (
"The model provider rejected the request as invalid. Review the agent's model "
"settings (output-token limit, temperature, effort). The provider's own message "
"is recorded on the LLM call span for this run."
)


def raise_for_llm_client_error(error: UiPathError) -> None:
"""Raise a structured agent error for known LLM-client error codes."""
Expand Down Expand Up @@ -62,11 +73,23 @@ def _is_license_error(body: object) -> bool:

def _classify(
status_code: int, body: object
) -> tuple[AgentRuntimeErrorCode, UiPathErrorCategory, str]:
"""Map an LLM provider HTTP status onto (code, category, title).
) -> tuple[AgentRuntimeErrorCode, UiPathErrorCategory, str, str | None]:
"""Map an LLM provider HTTP status onto (code, category, title, fallback_detail).

Only 400 and 403 are classified beyond the 5xx/other split. 404 is
deliberately left in UNKNOWN: every 404 observed in prod over 30 days was a
missing or unreachable model deployment (BYO relay not connected, Azure
DeploymentNotFound, a retired Bedrock model), which is Deployment rather
than User -- so it needs its own decision, not this one.

403 is the only status whose meaning depends on the body; keeping the code,
category and title decided in one place stops them drifting apart.
category, title and fallback detail decided in one place stops them drifting
apart.

``fallback_detail`` is the customer-facing text to use when the gateway
supplied no ProblemDetails ``detail`` of its own. ``None`` means "fall back
to the HTTP reason phrase" -- the two-word message that PC-5002 is about, so
only statuses whose cause we cannot name are left with it.
"""
if status_code == 403:
if _is_license_error(body):
Expand All @@ -77,17 +100,37 @@ def _classify(
title
if isinstance(title, str) and title.strip()
else "License not available",
None,
)
return (
AgentRuntimeErrorCode.LLM_PROVIDER_FORBIDDEN,
UiPathErrorCategory.DEPLOYMENT,
"LLM provider returned HTTP 403",
None,
)

if status_code == 400:
return (
AgentRuntimeErrorCode.LLM_PROVIDER_BAD_REQUEST,
UiPathErrorCategory.USER,
"LLM provider rejected the request",
_BAD_REQUEST_DETAIL,
)

title = f"LLM provider returned HTTP {status_code}"
if status_code >= 500:
return AgentRuntimeErrorCode.HTTP_ERROR, UiPathErrorCategory.SYSTEM, title
return AgentRuntimeErrorCode.HTTP_ERROR, UiPathErrorCategory.UNKNOWN, title
return (
AgentRuntimeErrorCode.HTTP_ERROR,
UiPathErrorCategory.SYSTEM,
title,
None,
)
return (
AgentRuntimeErrorCode.HTTP_ERROR,
UiPathErrorCategory.UNKNOWN,
title,
None,
)


def raise_for_provider_http_error(error: UiPathAPIError) -> NoReturn:
Expand All @@ -98,13 +141,13 @@ def raise_for_provider_http_error(error: UiPathAPIError) -> NoReturn:
"""
status_code = error.status_code
body = error.body
code, category, title = _classify(status_code, body)
detail = error.body.get("detail") if isinstance(error.body, dict) else None
code, category, title, fallback_detail = _classify(status_code, body)
gateway_detail = body.get("detail") if isinstance(body, dict) else None

raise AgentRuntimeError(
code=code,
title=title,
detail=detail or error.message or str(error),
detail=gateway_detail or fallback_detail or error.message or str(error),
category=category,
status=status_code,
) from error
53 changes: 53 additions & 0 deletions tests/agent/react/test_llm_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,59 @@ async def test_legacy_raw_provider_error_is_normalized_and_mapped(self):
assert info.status == 403
assert info.code.endswith(AgentRuntimeErrorCode.LICENSE_NOT_AVAILABLE.value)

@staticmethod
def _http_400() -> httpx.Response:
"""The 400 from job 1fab7e97-...: max_tokens written by Agent Builder."""
request = httpx.Request("POST", "http://gateway/")
return httpx.Response(
400,
request=request,
json={
"error": {
"message": (
"max_tokens is too large: 65535. This model supports at "
"most 32768 completion tokens."
),
"code": "invalid_value",
"param": "max_tokens",
}
},
)

@pytest.mark.asyncio
async def test_new_client_400_maps_to_user_without_the_provider_body(self):
# PC-5002: this reached telemetry as the two words "Bad Request",
# categorized Unknown. It is now User, with a canned actionable detail
# and no provider text.
node = self._node_raising(UiPathAPIError.from_response(self._http_400()))

with pytest.raises(AgentRuntimeError) as exc_info:
await node(self.state)

info = exc_info.value.error_info
assert info.status == 400
assert info.category == UiPathErrorCategory.USER
assert info.code.endswith(AgentRuntimeErrorCode.LLM_PROVIDER_BAD_REQUEST.value)
assert info.detail != "Bad Request"
assert "65535" not in info.detail

@pytest.mark.asyncio
async def test_legacy_400_maps_to_user(self):
raw = openai.BadRequestError(
"Bad Request",
response=self._http_400(),
body={"error": {"message": "max_tokens is too large: 65535."}},
)
node = self._node_raising(raw)

with pytest.raises(AgentRuntimeError) as exc_info:
await node(self.state)

info = exc_info.value.error_info
assert info.status == 400
assert info.category == UiPathErrorCategory.USER
assert info.code.endswith(AgentRuntimeErrorCode.LLM_PROVIDER_BAD_REQUEST.value)

@pytest.mark.asyncio
async def test_unmarked_403_maps_to_provider_forbidden_not_license(self):
# Regression for PC-5000 / SRE-654983: an edge or BYOM endpoint refuses
Expand Down
103 changes: 97 additions & 6 deletions tests/agent/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@
had.

Note on ``detail``: the mapper reads the gateway's ProblemDetails ``detail`` key
and otherwise falls back to ``UiPathAPIError.message`` -- the HTTP reason phrase.
A passthrough provider body is therefore *not* quoted back to the customer; an
unmarked 403 reports "Forbidden" whatever the upstream body contained. The tests
below pin that down as the current contract.
-- first-party UiPath text -- and never the vendor envelope. A passthrough
provider body is therefore *not* quoted back to the customer whatever it
contained. Where the gateway supplied no ``detail``, 400 falls back to a canned,
actionable message and everything else falls back to ``UiPathAPIError.message``,
the HTTP reason phrase (an unmarked 403 reports "Forbidden"). PC-5002: the
reason-phrase fallback is what made 49% of fleet failures two words long, so the
status that dominates that bucket now carries real text that is still free of
provider content.

The tests below pin all of that down as the current contract.
"""

import httpx
Expand Down Expand Up @@ -221,12 +227,97 @@ def test_5xx_maps_to_system_http_error():
assert "boom" in info.detail


def test_unclassified_status_remains_unknown():
err = _api_error(400, {"status": 400, "detail": "bad request"})
@pytest.mark.parametrize("status_code", [404, 408, 413, 422, 429])
def test_unclassified_4xx_remains_unknown(status_code: int):
"""Only 400 and 403 are classified; the rest of 4xx stays UNKNOWN.

404 is here on purpose. Every LLM-gateway 404 in prd over 30 days was a
missing or unreachable deployment -- BYO relay not connected, Azure
``DeploymentNotFound``, a retired Bedrock model -- i.e. Deployment, not
User. It is left UNKNOWN until that is decided on its own evidence rather
than folded into the 400 change.
"""
err = _api_error(status_code, {"status": status_code, "detail": "nope"})
info = _raise(err).error_info

assert info.category == UiPathErrorCategory.UNKNOWN
assert info.code.endswith(AgentRuntimeErrorCode.HTTP_ERROR.value)
assert info.title == f"LLM provider returned HTTP {status_code}"


# --------------------------------------------------------------------------
# 400: User, with a canned detail instead of the reason phrase
# --------------------------------------------------------------------------

# The body of the 400 that failed 192/192 runs on gpt-4.1-mini-e2e-custom
# (job 1fab7e97-...): max_tokens=65535 written by Agent Builder itself.
_MAX_TOKENS_BODY: dict[str, object] = {
"error": {
"message": (
"max_tokens is too large: 65535. This model supports at most 32768 "
"completion tokens, whereas you provided 65535."
),
"code": "invalid_value",
"param": "max_tokens",
}
}


@pytest.mark.parametrize(
"err_factory",
[
pytest.param(lambda: _api_error(400, _MAX_TOKENS_BODY), id="vendor-envelope"),
pytest.param(
lambda: _api_error(400, {"message": "Malformed input request."}),
id="bedrock-envelope",
),
pytest.param(lambda: _api_error_text(400, _EDGE_HTML), id="raw-html"),
pytest.param(lambda: _api_error(400, {}), id="empty-body"),
],
)
def test_400_maps_to_user_with_a_canned_detail(err_factory):
info = _raise(err_factory()).error_info

assert info.status == 400
assert info.category == UiPathErrorCategory.USER
assert info.code.endswith(AgentRuntimeErrorCode.LLM_PROVIDER_BAD_REQUEST.value)
assert info.title == "LLM provider rejected the request"
# The bare reason phrase is what PC-5002 is about -- it must be gone.
assert info.detail != "Bad Request"
assert "model settings" in info.detail


@pytest.mark.parametrize(
"err_factory",
[
pytest.param(lambda: _api_error(400, _MAX_TOKENS_BODY), id="vendor-envelope"),
pytest.param(lambda: _api_error_text(400, _EDGE_HTML), id="raw-html"),
],
)
def test_400_does_not_quote_the_provider_body(err_factory):
error = _raise(err_factory())

for rendered in (error.error_info.detail, str(error), repr(error)):
assert "65535" not in rendered
assert "doctype" not in rendered.lower()


def test_400_prefers_the_gateway_detail_over_the_canned_text():
"""A ProblemDetails ``detail`` is first-party UiPath text and more specific."""
err = _api_error(400, {"status": 400, "detail": "Model not enabled."})
info = _raise(err).error_info

assert info.detail == "Model not enabled."
assert info.category == UiPathErrorCategory.USER
assert info.code.endswith(AgentRuntimeErrorCode.LLM_PROVIDER_BAD_REQUEST.value)


def test_user_category_is_not_wrapped_in_the_generic_prefix():
"""USER is outside _SHOULD_WRAP_CATEGORIES, so the canned detail stands alone."""
info = _raise(_api_error(400, _MAX_TOKENS_BODY)).error_info

assert not info.detail.startswith("An unexpected error occurred")
assert info.detail.startswith("The model provider rejected the request")


def test_legacy_raw_provider_error_is_normalized_and_mapped():
Expand Down
Loading