Skip to content
Draft
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.14.17"
version = "0.14.18"
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>=0.2.0,<1.0.0",
"uipath-langchain-client[openai]>=1.17.1,<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.1,<1.18.0",
"uipath-langchain-client[anthropic]>=1.18.0,<1.19.0",
]
vertex = [
"uipath-langchain-client[google]>=1.17.1,<1.18.0",
"uipath-langchain-client[vertexai]>=1.17.1,<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.1,<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.1,<1.18.0",
"uipath-langchain-client[fireworks]>=1.18.0,<1.19.0",
]
all = [
"uipath-langchain-client[all]>=1.17.1,<1.18.0",
"uipath-langchain-client[all]>=1.18.0,<1.19.0",
]

[project.entry-points."uipath.middlewares"]
Expand Down
47 changes: 39 additions & 8 deletions src/uipath_langchain/agent/exceptions/licensing.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,51 @@
}


def _extract_provider_detail(body: object) -> str | None:
"""Pull the human-readable message out of an error response body.

Tries the gateway's own envelope (``{"detail": ...}``) first, then the
vendor envelopes forwarded on passthrough 4xx responses (OpenAI/Anthropic
``{"error": {"message": ...}}``, Vertex list-wrapped variants, flat
``{"message": ...}``), then falls back to a raw text body.
"""
if isinstance(body, list) and body:
return _extract_provider_detail(body[0])
if isinstance(body, dict):
detail = body.get("detail")
if isinstance(detail, str) and detail:
return detail
error = body.get("error")
if isinstance(error, dict):
message = error.get("message")
if isinstance(message, str) and message:
return message
message = body.get("message")
if isinstance(message, str) and message:
return message
if isinstance(body, str) and body.strip():
return body.strip()[:2000]
return None


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 ``detail`` (from ``error.body``)
and re-raises as an ``AgentRuntimeError`` chained on the original.
Reads the HTTP status code and the error message from ``error.body`` (the
gateway's ``detail`` envelope or the provider's own error envelope) and
re-raises as an ``AgentRuntimeError`` chained on the original. A 400 is the
caller's request/configuration, so it is categorised USER and surfaced
unwrapped; other unknown statuses keep the generic UNKNOWN wrapping.
"""
status_code = error.status_code
code = _LLM_STATUS_CODE_MAP.get(status_code, AgentRuntimeErrorCode.HTTP_ERROR)
category = (
UiPathErrorCategory.DEPLOYMENT
if status_code == 403
else UiPathErrorCategory.UNKNOWN
)
detail = error.body.get("detail") if isinstance(error.body, dict) else None
if status_code == 403:
category = UiPathErrorCategory.DEPLOYMENT
elif status_code == 400:
category = UiPathErrorCategory.USER
else:
category = UiPathErrorCategory.UNKNOWN
detail = _extract_provider_detail(error.body)

raise AgentRuntimeError(
code=code,
Expand Down
1 change: 0 additions & 1 deletion src/uipath_langchain/agent/react/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,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
15 changes: 13 additions & 2 deletions src/uipath_langchain/agent/react/conversational_output_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
output schema declares custom fields beyond `uipath__agent_response_messages`.
It performs a focused LLM call with only the `set_conversational_output`
tool bound and `tool_choice="any"` to extract the structured output for the turn.

The call runs with thinking stripped (and reasoning blocks dropped from the
replayed history): providers silently remove forced tool_choice while thinking
is on, and this call has no retry fallback — thinking off is what guarantees
the forcing is honored.
"""

from typing import TypeVar
Expand All @@ -26,6 +31,7 @@
from ..exceptions.licensing import raise_for_provider_http_error
from ..exceptions.llm import raise_for_llm_client_error
from ..tools.utils import config_without_streaming
from .forced_extraction import _strip_reasoning_blocks, _without_thinking
from .tools.tools import create_set_conversational_output_tool
from .types import AgentGraphState

Expand All @@ -49,7 +55,9 @@ def create_conversational_output_node(
agent_output_schema
)
# Disable streaming on this internal LLM call
non_streaming_model = model.model_copy(update={"disable_streaming": True})
non_streaming_model = _without_thinking(model).model_copy(
update={"disable_streaming": True}
)
payload_handler = get_payload_handler(non_streaming_model)
binding_kwargs = payload_handler.get_tool_binding_kwargs(
tools=[set_conversational_output_tool],
Expand All @@ -62,7 +70,10 @@ def create_conversational_output_node(
output_prompt = get_generate_output_prompt()

async def conversational_output_node(state: StateT):
messages = [*state.messages, HumanMessage(content=output_prompt)]
messages = [
*_strip_reasoning_blocks(list(state.messages)),
HumanMessage(content=output_prompt),
]
config = config_without_streaming(var_child_runnable_config.get(None))

try:
Expand Down
109 changes: 109 additions & 0 deletions src/uipath_langchain/agent/react/forced_extraction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""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 typing import Any

from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
from uipath.agent.react import END_EXECUTION_TOOL

from .utils import _REASONING_BLOCK_TYPES

_END_EXECUTION_NAME = getattr(
END_EXECUTION_TOOL.name, "value", str(END_EXECUTION_TOOL.name)
)


def _without_thinking(model: BaseChatModel) -> BaseChatModel:
"""Copy of the model with thinking config stripped, so forcing is honored.

Thinking lives in a different place per transport: native `thinking`, Bedrock Invoke
`model_kwargs`, Bedrock Converse `additional_model_request_fields`.
"""
updates: dict[str, object] = {}
request_fields = getattr(model, "additional_model_request_fields", None)
if isinstance(request_fields, dict) and (
"thinking" in request_fields or "output_config" in request_fields
):
updates["additional_model_request_fields"] = {
k: v
for k, v in request_fields.items()
if k not in ("thinking", "output_config")
}
model_kwargs = getattr(model, "model_kwargs", None)
if isinstance(model_kwargs, dict) and "thinking" in model_kwargs:
updates["model_kwargs"] = {
k: v for k, v in model_kwargs.items() if k != "thinking"
}
if getattr(model, "thinking", None) is not None:
updates["thinking"] = None
if not updates:
return model
try:
return model.model_copy(update=updates)
except Exception:
return model


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 (
isinstance(block, dict)
and block.get("type") in _REASONING_BLOCK_TYPES
)
]
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 _with_extraction_nudge(messages: list[AnyMessage]) -> list[AnyMessage]:
"""Append (or merge into) a trailing user turn telling the model to call a tool.

The wording is tool-neutral: continue the task, or finish with end_execution — so a
multi-tool agent that stalled mid-task isn't pushed to end early. Has to end on a user
turn: native/Vertex rejects a forced call that ends on the stalled assistant turn (a
prefill). Merge instead of appending so roles stay alternating even if the stalled turn
was dropped as empty.
"""
nudge = (
f"Call the tool to continue the task. If you've finished, call "
f"{_END_EXECUTION_NAME} with the result."
)
if messages and isinstance(messages[-1], HumanMessage):
last = messages[-1]
if isinstance(last.content, str):
merged: Any = f"{last.content}\n\n{nudge}" if last.content else nudge
elif isinstance(last.content, list):
merged = list(last.content) + [{"type": "text", "text": nudge}]
else:
merged = nudge
return list(messages[:-1]) + [HumanMessage(content=merged)]
return list(messages) + [HumanMessage(content=nudge)]


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, and a nudge to call end_execution — the caller then forces tool_choice."""
return _without_thinking(model), _with_extraction_nudge(
_strip_reasoning_blocks(messages)
)
47 changes: 35 additions & 12 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.runtime.errors import UiPathErrorCategory

from uipath_langchain.chat.handlers import get_payload_handler
from uipath_langchain.chat.handlers.anthropic import anthropic_thinking_type

from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode
from ..exceptions.licensing import raise_for_provider_http_error
Expand All @@ -26,6 +27,7 @@
DEFAULT_MAX_CONSECUTIVE_THINKING_MESSAGES,
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

Expand Down Expand Up @@ -71,7 +73,13 @@ def create_llm_node(
"""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.
to prevent infinite loops and ensure progress. Stall accounting is
provider-agnostic, because forcing can be silently downgraded on any
transport (Bedrock handlers under thinking, langchain_anthropic) or
ignored by a BYOM deployment: after `thinking_messages_limit` tool-less
turns tool_choice is forced, one more tool-less turn retries via the
forced-extraction call (thinking off, which every provider honors), and
a further stall raises THINKING_LIMIT_EXCEEDED.

Args:
model: The chat model to use
Expand Down Expand Up @@ -103,25 +111,40 @@ 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
):
current_tool_choice = "any"

binding_kwargs = payload_handler.get_tool_binding_kwargs(
consecutive_thinking = count_consecutive_thinking_messages(messages)
uses_anthropic_thinking = anthropic_thinking_type(model) is not None
effective_limit = thinking_messages_limit if uses_anthropic_thinking else 0
call_model: BaseChatModel = model
call_messages: list[AnyMessage] = messages
handler = payload_handler
if not is_conversational and bindable_tools:
if consecutive_thinking > effective_limit + 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,
)
if consecutive_thinking >= effective_limit:
current_tool_choice = "any"
if uses_anthropic_thinking and consecutive_thinking > 0:
call_model, call_messages = build_extraction_call(model, messages)
handler = get_payload_handler(call_model)

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