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

_REASONING_BLOCK_TYPES = {"reasoning_content", "reasoning", "thinking"}
_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 end_execution.

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"Provide the final result now by calling the {_END_EXECUTION_NAME} tool."
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)
)
26 changes: 19 additions & 7 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 @@ -104,24 +106,34 @@ async def llm_node(state: StateT):
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
uses_anthropic_thinking = anthropic_thinking_type(model) is not None
consecutive_thinking = count_consecutive_thinking_messages(messages)
effective_limit = thinking_messages_limit if uses_anthropic_thinking else 0
thinking_model_stalled = uses_anthropic_thinking and consecutive_thinking > 0
call_model: BaseChatModel = model
call_messages: list[AnyMessage] = messages
handler = payload_handler
if (
current_tool_choice == "auto"
and not is_conversational
and bindable_tools
and count_consecutive_thinking_messages(messages) >= thinking_messages_limit
and consecutive_thinking >= effective_limit
):
current_tool_choice = "any"
if thinking_model_stalled:
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
31 changes: 14 additions & 17 deletions src/uipath_langchain/agent/react/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,20 @@
from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode
from .types import FLOW_CONTROL_TOOLS, AgentGraphNode, AgentGraphState
from .utils import (
count_consecutive_thinking_messages,
extract_current_tool_call_index,
find_latest_ai_message,
has_reasoning_block,
)


def create_route_agent(
thinking_messages_limit: int = 0,
valid_targets: Container[str] | None = None,
):
"""Create a routing function configured with thinking_messages_limit.
"""Create the conditional-edge routing function.

Args:
thinking_messages_limit: Max consecutive thinking messages before error
valid_targets: Allowed routing destinations

Returns:
Routing function for LangGraph conditional edges
"""
Expand Down Expand Up @@ -57,28 +56,26 @@ def route_agent(
)

if not last_message.tool_calls:
consecutive_thinking_messages = count_consecutive_thinking_messages(
messages
)
# reasoning stall — loop so the next turn can force the extraction
if has_reasoning_block(last_message):
return AgentGraphNode.AGENT

if consecutive_thinking_messages > thinking_messages_limit:
# content but no tool call and no reasoning: the model ignored tool_choice
if last_message.content:
raise AgentRuntimeError(
code=AgentRuntimeErrorCode.THINKING_LIMIT_EXCEEDED,
title="Agent exceeded consecutive completions limit without producing tool calls.",
detail=f"Completions: {consecutive_thinking_messages}, max: {thinking_messages_limit}. "
f"This should not happen as tool_choice='required' is enforced at the limit."
"If you are using a BYOM configuration, verify your model deployment respects tool_choice or equivalent.",
title="Agent produced a response without calling a tool.",
detail="The model returned content but no tool call and no reasoning, "
"despite a forced tool_choice. If you are using a BYOM configuration, "
"verify your model deployment respects tool_choice.",
category=UiPathErrorCategory.SYSTEM,
)

if last_message.content:
return AgentGraphNode.AGENT

raise AgentRuntimeError(
code=AgentRuntimeErrorCode.ROUTING_ERROR,
title="Agent produced empty response without tool calls.",
detail=f"Consecutive completions: {consecutive_thinking_messages}, has_content: False."
"If you are using a BYOM configuration, verify your model deployment",
detail="The model returned no content and no tool calls. "
"If you are using a BYOM configuration, verify your model deployment.",
category=UiPathErrorCategory.SYSTEM,
)

Expand Down
19 changes: 19 additions & 0 deletions src/uipath_langchain/agent/react/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,25 @@ def extract_input_data_from_state(
return input_model.model_validate(filtered_state, from_attributes=True).model_dump()


_REASONING_BLOCK_TYPES = {"reasoning_content", "reasoning", "thinking"}


def has_reasoning_block(message: BaseMessage) -> bool:
"""Whether an AI message carries a reasoning/thinking content block.

Lets the router tell a real reasoning stall (loop) from a model that just ignored a
forced tool_choice (error). A thinking turn always has the block, even with the
display omitted (empty text + signature); plain text has none.
"""
content = getattr(message, "content", None)
if not isinstance(content, list):
return False
return any(
isinstance(block, dict) and block.get("type") in _REASONING_BLOCK_TYPES
for block in content
)


def count_consecutive_thinking_messages(messages: Sequence[BaseMessage]) -> int:
"""Count consecutive AIMessages without tool calls at end of message history."""
if not messages:
Expand Down
3 changes: 3 additions & 0 deletions src/uipath_langchain/chat/chat_model_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
before the ``uipath_langchain_client`` migration.
"""

from collections.abc import Mapping
from typing import Any, Final

from langchain_core.callbacks import BaseCallbackHandler, Callbacks
Expand Down Expand Up @@ -87,6 +88,7 @@ def get_chat_model(
callbacks: Callbacks = _UNSET,
agenthub_config: str | None = None,
use_new_llm_clients: bool = True,
model_settings: Mapping[str, Any] | None = None,
**kwargs: Any,
) -> BaseChatModel:
"""Create and configure a chat model, dispatching legacy vs new clients.
Expand Down Expand Up @@ -164,6 +166,7 @@ def get_chat_model(
api_flavor=api_flavor,
custom_class=custom_class,
agenthub_config=agenthub_config,
model_settings=model_settings,
**optional_kwargs,
**kwargs,
)
Expand Down
22 changes: 22 additions & 0 deletions src/uipath_langchain/chat/handlers/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@
from ..exceptions import ChatModelError, ChatModelErrorCode
from .base import ModelPayloadHandler


def anthropic_thinking_type(model: Any) -> str | None:
"""The Anthropic thinking mode for a model ("enabled"/"adaptive"/...), or None.

Reads the `thinking` dict wherever the transport puts it: native `thinking`, Bedrock
Invoke `model_kwargs`, Bedrock Converse `additional_model_request_fields`. Only
Anthropic uses this shape — OpenAI/Gemini use other knobs — so non-Anthropic models
return None. Null-safe.
"""
invoke = getattr(model, "model_kwargs", None) or {}
converse = getattr(model, "additional_model_request_fields", None) or {}
candidates = (
getattr(model, "thinking", None),
invoke.get("thinking") if isinstance(invoke, dict) else None,
converse.get("thinking") if isinstance(converse, dict) else None,
)
for thinking in candidates:
if isinstance(thinking, dict) and isinstance(thinking.get("type"), str):
return thinking["type"]
return None


FAULTY_STOP_REASONS: set[str] = {
"max_tokens",
"refusal",
Expand Down
Loading