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
16 changes: 8 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-langchain"
version = "0.16.5"
version = "0.16.6"
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 @@ -26,7 +26,7 @@ dependencies = [
"pillow>=12.1.1",
"rdflib>=7.0.0, <8.0.0",
"a2a-sdk>=1.1.2,<2.0.0",
"uipath-langchain-client[openai]>=1.17.3,<1.18.0",
"uipath-langchain-client[openai]>=1.18.0,<1.19.0",
]

classifiers = [
Expand All @@ -43,21 +43,21 @@ maintainers = [

[project.optional-dependencies]
anthropic = [
"uipath-langchain-client[anthropic]>=1.17.3,<1.18.0",
"uipath-langchain-client[anthropic]>=1.18.0,<1.19.0",
]
vertex = [
"uipath-langchain-client[google]>=1.17.3,<1.18.0",
"uipath-langchain-client[vertexai]>=1.17.3,<1.18.0",
"uipath-langchain-client[google]>=1.18.0,<1.19.0",
"uipath-langchain-client[vertexai]>=1.18.0,<1.19.0",
]
bedrock = [
"uipath-langchain-client[bedrock]>=1.17.3,<1.18.0",
"uipath-langchain-client[bedrock]>=1.18.0,<1.19.0",
"boto3-stubs>=1.41.4",
]
fireworks = [
"uipath-langchain-client[fireworks]>=1.17.3,<1.18.0",
"uipath-langchain-client[fireworks]>=1.18.0,<1.19.0",
]
all = [
"uipath-langchain-client[all]>=1.17.3,<1.18.0",
"uipath-langchain-client[all]>=1.18.0,<1.19.0",
]

[project.entry-points."uipath.middlewares"]
Expand Down
52 changes: 0 additions & 52 deletions src/uipath_langchain/agent/exceptions/licensing.py

This file was deleted.

58 changes: 56 additions & 2 deletions src/uipath_langchain/agent/exceptions/llm.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
"""Map normalized LLM-client errors into agent runtime errors."""
"""Map normalized LLM-client errors into agent runtime errors.

from uipath.llm_client import UiPathError, UiPathLLMErrorCode
The LLM client (uipath-llm-client / uipath-langchain-client) surfaces two shapes:
a ``UiPathError`` carrying a semantic ``error_code`` (handled by
``raise_for_llm_client_error``), and a ``UiPathAPIError`` carrying an HTTP
``status_code`` + ``body`` for provider passthrough failures (handled by
``raise_for_provider_http_error``). Both are mapped to ``AgentRuntimeError`` so
upstream handling can categorise without provider-specific logic.
"""

from typing import NoReturn

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

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

# Maps known LLM Gateway status codes to specific error codes.
# Unknown status codes fall back to HTTP_ERROR.
_LLM_STATUS_CODE_MAP: dict[int, AgentRuntimeErrorCode] = {
403: AgentRuntimeErrorCode.LICENSE_NOT_AVAILABLE,
}

# fixed, provider-free fallback when the gateway gives no ``detail``
_GENERIC_HTTP_DETAIL = (
"The request to the model provider failed. See the execution trace for details."
)


def raise_for_llm_client_error(error: UiPathError) -> None:
"""Raise a structured agent error for known LLM-client error codes."""
Expand All @@ -22,3 +43,36 @@ def raise_for_llm_client_error(error: UiPathError) -> None:
),
category=UiPathErrorCategory.USER,
) from error


def _category_for_status(status_code: int) -> UiPathErrorCategory:
"""Map LLM provider HTTP statuses to their runtime error category."""
if status_code == 403:
return UiPathErrorCategory.DEPLOYMENT
if status_code >= 500:
return UiPathErrorCategory.SYSTEM
return UiPathErrorCategory.UNKNOWN


def raise_for_provider_http_error(error: UiPathAPIError) -> NoReturn:
"""Convert a normalized ``UiPathAPIError`` into a structured ``AgentRuntimeError``.

Reads the HTTP status code and the gateway's own ``detail`` (from ``error.body``)
and re-raises. Only the gateway ``detail`` is surfaced; when it's absent a fixed
generic message is used instead of any provider/body content. The vendor's
passthrough message can echo request content, so it stays on the trace span
(excluded from App Insights), never the run record. ``from None`` keeps the chained
``UiPathAPIError`` string (which embeds the raw body) out of ``format_exc()`` too.
"""
status_code = error.status_code
code = _LLM_STATUS_CODE_MAP.get(status_code, AgentRuntimeErrorCode.HTTP_ERROR)
category = _category_for_status(status_code)
detail = error.body.get("detail") if isinstance(error.body, dict) else None

raise AgentRuntimeError(
code=code,
title=f"LLM provider returned HTTP {status_code}",
detail=detail or _GENERIC_HTTP_DETAIL,
category=category,
status=status_code,
) from None
Comment thread
tudormatei1 marked this conversation as resolved.
2 changes: 0 additions & 2 deletions src/uipath_langchain/agent/react/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,6 @@ def create_agent(
input_schema=input_schema,
is_conversational=config.is_conversational,
llm_messages_limit=config.llm_messages_limit,
thinking_messages_limit=config.thinking_messages_limit,
tool_choice=config.tool_choice,
parallel_tool_calls=config.parallel_tool_calls,
strict_mode=config.strict_mode,
Expand Down Expand Up @@ -219,7 +218,6 @@ def create_agent(
]
route_agent = create_route_agent(
valid_targets=target_node_names,
thinking_messages_limit=config.thinking_messages_limit,
)

builder.add_conditional_edges(
Expand Down
1 change: 0 additions & 1 deletion src/uipath_langchain/agent/react/constants.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
DEFAULT_MAX_CONSECUTIVE_THINKING_MESSAGES = 0
DEFAULT_MAX_LLM_MESSAGES = 25

UIPATH_CONVERSATIONAL_AGENT_RESPONSE_MESSAGES_FIELD = "uipath__agent_response_messages"
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
from uipath_langchain.chat.handlers import get_payload_handler

from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode
from ..exceptions.licensing import raise_for_provider_http_error
from ..exceptions.llm import raise_for_llm_client_error
from ..exceptions.llm import (
raise_for_llm_client_error,
raise_for_provider_http_error,
)
from ..tools.utils import config_without_streaming
from .tools.tools import create_set_conversational_output_tool
from .types import AgentGraphState
Expand Down
49 changes: 49 additions & 0 deletions src/uipath_langchain/agent/react/forced_extraction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Force a structured end_execution out of a thinking model that stalled.

Anthropic won't honor a forced tool_choice while thinking is on, so a thinking model can
answer in plain text and never call end_execution. build_extraction_call retries that
turn with thinking off and the tool call forced, which every provider honors.
"""

from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage

from uipath_langchain.chat.thinking import is_reasoning_block, strip_thinking


def _strip_reasoning_blocks(messages: list[AnyMessage]) -> list[AnyMessage]:
"""Drop reasoning blocks from AI messages (keep text + tool calls).

They can't be replayed on a thinking-off call — an orphaned thinking block 400s.
"""
stripped: list[AnyMessage] = []
for message in messages:
if isinstance(message, AIMessage) and isinstance(message.content, list):
kept = [block for block in message.content if not is_reasoning_block(block)]
if len(kept) != len(message.content):
# a turn that was only reasoning is now empty — drop it
if not kept and not message.tool_calls:
continue
message = message.model_copy(update={"content": kept})
stripped.append(message)
return stripped


def _ensure_trailing_user_turn(messages: list[AnyMessage]) -> list[AnyMessage]:
if messages and isinstance(messages[-1], (HumanMessage, ToolMessage)):
return messages
return list(messages) + [
HumanMessage(
content="Call a tool to continue. Terminal tool calls must contain the final output."
)
]


def build_extraction_call(
model: BaseChatModel, messages: list[AnyMessage]
) -> tuple[BaseChatModel, list[AnyMessage]]:
"""The (model, messages) for the extraction call: thinking off, reasoning blocks
dropped, ending on a user turn — the caller then forces tool_choice."""
return strip_thinking(model), _ensure_trailing_user_turn(
_strip_reasoning_blocks(messages)
)
58 changes: 37 additions & 21 deletions src/uipath_langchain/agent/react/llm_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,19 @@
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain.chat.handlers import get_payload_handler
from uipath_langchain.chat.thinking import thinking_rejects_forced_tool_choice

from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode
from ..exceptions.licensing import raise_for_provider_http_error
from ..exceptions.llm import raise_for_llm_client_error
from ..exceptions.llm import (
raise_for_llm_client_error,
raise_for_provider_http_error,
)
from ..messages.message_utils import replace_tool_calls
from ..tools.static_args import StaticArgsHandler
from .constants import (
DEFAULT_MAX_CONSECUTIVE_THINKING_MESSAGES,
DEFAULT_MAX_LLM_MESSAGES,
)
from .constants import DEFAULT_MAX_LLM_MESSAGES
from .forced_extraction import build_extraction_call
from .types import FLOW_CONTROL_TOOLS, AgentGraphState
from .utils import count_consecutive_thinking_messages
from .utils import count_consecutive_tool_less_turns


def _filter_control_flow_tool_calls(
Expand Down Expand Up @@ -63,23 +64,24 @@ def create_llm_node(
input_schema: type[InputT] | None = None,
is_conversational: bool = False,
llm_messages_limit: int = DEFAULT_MAX_LLM_MESSAGES,
thinking_messages_limit: int = DEFAULT_MAX_CONSECUTIVE_THINKING_MESSAGES,
tool_choice: Literal["auto", "any"] = "auto",
parallel_tool_calls: bool = True,
strict_mode: bool = False,
):
"""Create LLM node with dynamic tool_choice enforcement.

Controls when to force tool usage based on consecutive thinking steps
to prevent infinite loops and ensure progress.
Forces tool usage every turn to keep the agent making progress. Forcing can
be silently downgraded on any transport (Bedrock handlers under thinking,
langchain_anthropic) or ignored by a BYOM deployment, so a tool-less turn is
tolerated: Anthropic thinking models retry it via the forced-extraction call
(thinking off, which every provider honors), and a second stall raises
THINKING_LIMIT_EXCEEDED.

Args:
model: The chat model to use
tools: Available tools to bind
is_conversational: Whether this is a conversational agent
llm_messages_limit: Maximum number of LLM calls allowed per execution
thinking_messages_limit: Max consecutive LLM responses without tool calls
before enforcing tool usage. 0 = force tools every time.
"""
bindable_tools = list(tools) if tools else []
payload_handler = get_payload_handler(model)
Expand All @@ -103,25 +105,39 @@ async def llm_node(state: StateT):
static_schema_tools = static_args_handler.initialize(
bindable_tools, state, input_schema or type(state)
)

current_tool_choice: Literal["auto", "any"] = tool_choice
if current_tool_choice == "auto" and (
not is_conversational
and bindable_tools
and count_consecutive_thinking_messages(messages) >= thinking_messages_limit
):
consecutive_tool_less = count_consecutive_tool_less_turns(messages)
thinking_rejects_forcing = thinking_rejects_forced_tool_choice(model)
call_model: BaseChatModel = model
call_messages: list[AnyMessage] = messages
handler = payload_handler
if not is_conversational and bindable_tools:
# only one tool_choice=auto call that doesnt return tool is allowed
Comment thread
tudormatei1 marked this conversation as resolved.
if consecutive_tool_less > 1:
raise AgentRuntimeError(
code=AgentRuntimeErrorCode.THINKING_LIMIT_EXCEEDED,
title="Agent kept responding without calling a tool.",
detail="The model produced consecutive responses without tool calls "
"even after the forced extraction retry. If you are using a BYOM "
"configuration, verify your model deployment respects tool_choice.",
category=UiPathErrorCategory.SYSTEM,
)
current_tool_choice = "any"
if thinking_rejects_forcing and consecutive_tool_less > 0:
call_model, call_messages = build_extraction_call(model, messages)
handler = get_payload_handler(call_model)

binding_kwargs = payload_handler.get_tool_binding_kwargs(
binding_kwargs = handler.get_tool_binding_kwargs(
tools=static_schema_tools,
tool_choice=current_tool_choice,
parallel_tool_calls=parallel_tool_calls,
strict_mode=strict_mode,
)

llm = model.bind_tools(static_schema_tools, **binding_kwargs)
llm = call_model.bind_tools(static_schema_tools, **binding_kwargs)

try:
response = await llm.ainvoke(messages)
response = await llm.ainvoke(call_messages)
except UiPathAPIError as e:
# New LLM clients surface provider HTTP errors as a normalized UiPathAPIError directly.
raise_for_provider_http_error(e)
Expand Down
Loading
Loading