Skip to content

Commit dd7c1a3

Browse files
Merge pull request #359 from UiPath/feat/configurable-successive-optional-llms
feat: add config for agent allowed consecutive thinking messages
2 parents 973b2eb + 6accf63 commit dd7c1a3

9 files changed

Lines changed: 116 additions & 69 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.1.33"
3+
version = "0.1.34"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/agent/react/agent.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
create_llm_node,
2525
)
2626
from .router import (
27-
route_agent,
27+
create_route_agent,
2828
)
2929
from .terminate_node import (
3030
create_terminate_node,
@@ -58,7 +58,7 @@ def create_agent(
5858
config: AgentGraphConfig | None = None,
5959
guardrails: Sequence[tuple[BaseGuardrail, GuardrailAction]] | None = None,
6060
) -> StateGraph[AgentGraphState, None, InputT, OutputT]:
61-
"""Build agent graph with INIT -> AGENT(subgraph) <-> TOOLS loop, terminated by control flow tools.
61+
"""Build agent graph with INIT -> AGENT (subgraph) <-> TOOLS loop, terminated by control flow tools.
6262
6363
The AGENT node is a subgraph that runs:
6464
- before-agent guardrail middlewares
@@ -107,14 +107,15 @@ def create_agent(
107107

108108
builder.add_edge(START, AgentGraphNode.INIT)
109109

110-
llm_node = create_llm_node(model, llm_tools)
110+
llm_node = create_llm_node(model, llm_tools, config.thinking_messages_limit)
111111
llm_with_guardrails_subgraph = create_llm_guardrails_subgraph(
112112
(AgentGraphNode.LLM, llm_node), guardrails
113113
)
114114
builder.add_node(AgentGraphNode.AGENT, llm_with_guardrails_subgraph)
115115
builder.add_edge(AgentGraphNode.INIT, AgentGraphNode.AGENT)
116116

117117
tool_node_names = list(tool_nodes_with_guardrails.keys())
118+
route_agent = create_route_agent(config.thinking_messages_limit)
118119
builder.add_conditional_edges(
119120
AgentGraphNode.AGENT,
120121
route_agent,
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
# Agent routing configuration
2-
MAX_SUCCESSIVE_COMPLETIONS = 1
1+
MAX_CONSECUTIVE_THINKING_MESSAGES = 0

src/uipath_langchain/agent/react/llm_node.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,65 @@
1-
"""LLM node implementation for LangGraph."""
1+
"""LLM node for ReAct Agent graph."""
22

3-
from typing import Sequence
3+
from typing import Literal, Sequence
44

55
from langchain_core.language_models import BaseChatModel
66
from langchain_core.messages import AIMessage, AnyMessage
77
from langchain_core.tools import BaseTool
88

9-
from .constants import MAX_SUCCESSIVE_COMPLETIONS
9+
from .constants import MAX_CONSECUTIVE_THINKING_MESSAGES
1010
from .types import AgentGraphState
11-
from .utils import count_successive_completions
11+
from .utils import count_consecutive_thinking_messages
12+
13+
OPENAI_COMPATIBLE_CHAT_MODELS = (
14+
"UiPathChatOpenAI",
15+
"AzureChatOpenAI",
16+
"ChatOpenAI",
17+
"UiPathChat",
18+
"UiPathAzureChatOpenAI",
19+
)
20+
21+
22+
def _get_required_tool_choice_by_model(
23+
model: BaseChatModel,
24+
) -> Literal["required", "any"]:
25+
"""Get the appropriate tool_choice value to enforce tool usage based on model type.
26+
27+
"required" - OpenAI compatible required tool_choice value
28+
"any" - Vertex and Bedrock parameter for required tool_choice value
29+
"""
30+
model_class_name = model.__class__.__name__
31+
if model_class_name in OPENAI_COMPATIBLE_CHAT_MODELS:
32+
return "required"
33+
return "any"
1234

1335

1436
def create_llm_node(
1537
model: BaseChatModel,
1638
tools: Sequence[BaseTool] | None = None,
39+
thinking_messages_limit: int = MAX_CONSECUTIVE_THINKING_MESSAGES,
1740
):
18-
"""Invoke LLM with tools and dynamically control tool_choice based on successive completions.
41+
"""Create LLM node with dynamic tool_choice enforcement.
1942
20-
When successive completions reach the limit, tool_choice is set to "required" to force
21-
the LLM to use a tool and prevent infinite reasoning loops.
43+
Controls when to force tool usage based on consecutive thinking steps
44+
to prevent infinite loops and ensure progress.
45+
46+
Args:
47+
model: The chat model to use
48+
tools: Available tools to bind
49+
thinking_messages_limit: Max consecutive LLM responses without tool calls
50+
before enforcing tool usage. 0 = force tools every time.
2251
"""
2352
bindable_tools = list(tools) if tools else []
2453
base_llm = model.bind_tools(bindable_tools) if bindable_tools else model
54+
tool_choice_required_value = _get_required_tool_choice_by_model(model)
2555

2656
async def llm_node(state: AgentGraphState):
2757
messages: list[AnyMessage] = state.messages
2858

29-
successive_completions = count_successive_completions(messages)
30-
if successive_completions >= MAX_SUCCESSIVE_COMPLETIONS:
31-
llm = base_llm.bind(tool_choice="required")
59+
consecutive_thinking_messages = count_consecutive_thinking_messages(messages)
60+
61+
if bindable_tools and consecutive_thinking_messages >= thinking_messages_limit:
62+
llm = base_llm.bind(tool_choice=tool_choice_required_value)
3263
else:
3364
llm = base_llm
3465

src/uipath_langchain/agent/react/router.py

Lines changed: 48 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@
66
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
77

88
from ..exceptions import AgentNodeRoutingException
9-
from .constants import MAX_SUCCESSIVE_COMPLETIONS
109
from .types import AgentGraphNode, AgentGraphState
11-
from .utils import count_successive_completions
10+
from .utils import count_consecutive_thinking_messages
1211

1312
FLOW_CONTROL_TOOLS = [END_EXECUTION_TOOL.name, RAISE_ERROR_TOOL.name]
1413

@@ -48,50 +47,62 @@ def __validate_last_message_is_AI(messages: list[AnyMessage]) -> AIMessage:
4847
return last_message
4948

5049

51-
def route_agent(
52-
state: AgentGraphState,
53-
) -> list[str] | Literal[AgentGraphNode.AGENT, AgentGraphNode.TERMINATE]:
54-
"""Route after agent: handles all routing logic including control flow detection.
50+
def create_route_agent(thinking_messages_limit: int = 0):
51+
"""Create a routing function configured with thinking_messages_limit.
5552
56-
Routing logic:
57-
1. If multiple tool calls exist, filter out control flow tools (EndExecution, RaiseError)
58-
2. If control flow tool(s) remain, route to TERMINATE
59-
3. If regular tool calls remain, route to specific tool nodes (return list of tool names)
60-
4. If no tool calls, handle successive completions
53+
Args:
54+
thinking_messages_limit: Max consecutive thinking messages before error
6155
6256
Returns:
63-
- list[str]: Tool node names for parallel execution
64-
- AgentGraphNode.AGENT: For successive completions
65-
- AgentGraphNode.TERMINATE: For control flow termination
66-
67-
Raises:
68-
AgentNodeRoutingException: When encountering unexpected state (empty messages, non-AIMessage, or excessive completions)
57+
Routing function for LangGraph conditional edges
6958
"""
70-
messages = state.messages
71-
last_message = __validate_last_message_is_AI(messages)
7259

73-
tool_calls = list(last_message.tool_calls) if last_message.tool_calls else []
74-
tool_calls = __filter_control_flow_tool_calls(tool_calls)
60+
def route_agent(
61+
state: AgentGraphState,
62+
) -> list[str] | Literal[AgentGraphNode.AGENT, AgentGraphNode.TERMINATE]:
63+
"""Route after agent: handles all routing logic including control flow detection.
64+
65+
Routing logic:
66+
1. If multiple tool calls exist, filter out control flow tools (EndExecution, RaiseError)
67+
2. If control flow tool(s) remain, route to TERMINATE
68+
3. If regular tool calls remain, route to specific tool nodes (return list of tool names)
69+
4. If no tool calls, handle consecutive completions
70+
71+
Returns:
72+
- list[str]: Tool node names for parallel execution
73+
- AgentGraphNode.AGENT: For consecutive completions
74+
- AgentGraphNode.TERMINATE: For control flow termination
75+
76+
Raises:
77+
AgentNodeRoutingException: When encountering unexpected state (empty messages, non-AIMessage, or excessive completions)
78+
"""
79+
messages = state.messages
80+
last_message = __validate_last_message_is_AI(messages)
7581

76-
if tool_calls and __has_control_flow_tool(tool_calls):
77-
return AgentGraphNode.TERMINATE
82+
tool_calls = list(last_message.tool_calls) if last_message.tool_calls else []
83+
tool_calls = __filter_control_flow_tool_calls(tool_calls)
7884

79-
if tool_calls:
80-
return [tc["name"] for tc in tool_calls]
85+
if tool_calls and __has_control_flow_tool(tool_calls):
86+
return AgentGraphNode.TERMINATE
8187

82-
successive_completions = count_successive_completions(messages)
88+
if tool_calls:
89+
return [tc["name"] for tc in tool_calls]
90+
91+
consecutive_thinking_messages = count_consecutive_thinking_messages(messages)
92+
93+
if consecutive_thinking_messages > thinking_messages_limit:
94+
raise AgentNodeRoutingException(
95+
f"Agent exceeded consecutive completions limit without producing tool calls "
96+
f"(completions: {consecutive_thinking_messages}, max: {thinking_messages_limit}). "
97+
f"This should not happen as tool_choice='required' is enforced at the limit."
98+
)
99+
100+
if last_message.content:
101+
return AgentGraphNode.AGENT
83102

84-
if successive_completions > MAX_SUCCESSIVE_COMPLETIONS:
85103
raise AgentNodeRoutingException(
86-
f"Agent exceeded successive completions limit without producing tool calls "
87-
f"(completions: {successive_completions}, max: {MAX_SUCCESSIVE_COMPLETIONS}). "
88-
f"This should not happen as tool_choice='required' is enforced at the limit."
104+
f"Agent produced empty response without tool calls "
105+
f"(completions: {consecutive_thinking_messages}, has_content: False)"
89106
)
90107

91-
if last_message.content:
92-
return AgentGraphNode.AGENT
93-
94-
raise AgentNodeRoutingException(
95-
f"Agent produced empty response without tool calls "
96-
f"(completions: {successive_completions}, has_content: False)"
97-
)
108+
return route_agent

src/uipath_langchain/agent/react/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,8 @@ class AgentGraphConfig(BaseModel):
4646
recursion_limit: int = Field(
4747
default=50, ge=1, description="Maximum recursion limit for the agent graph"
4848
)
49+
thinking_messages_limit: int = Field(
50+
default=0,
51+
ge=0,
52+
description="Max consecutive thinking messages before enforcing tool usage. 0 = force tools every time.",
53+
)

src/uipath_langchain/agent/react/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def resolve_output_model(
2828
return END_EXECUTION_TOOL.args_schema
2929

3030

31-
def count_successive_completions(messages: Sequence[BaseMessage]) -> int:
31+
def count_consecutive_thinking_messages(messages: Sequence[BaseMessage]) -> int:
3232
"""Count consecutive AIMessages without tool calls at end of message history."""
3333
if not messages:
3434
return 0

tests/agent/react/test_utils.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,28 @@
22

33
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
44

5-
from uipath_langchain.agent.react.utils import count_successive_completions
5+
from uipath_langchain.agent.react.utils import count_consecutive_thinking_messages
66

77

88
class TestCountSuccessiveCompletions:
99
"""Test successive completions calculation from message history."""
1010

1111
def test_empty_messages(self):
1212
"""Should return 0 for empty message list."""
13-
assert count_successive_completions([]) == 0
13+
assert count_consecutive_thinking_messages([]) == 0
1414

1515
def test_no_ai_messages(self):
1616
"""Should return 0 when no AI messages exist."""
1717
messages = [HumanMessage(content="test")]
18-
assert count_successive_completions(messages) == 0
18+
assert count_consecutive_thinking_messages(messages) == 0
1919

2020
def test_last_message_not_ai(self):
2121
"""Should return 0 when last message is not AI."""
2222
messages = [
2323
AIMessage(content="response"),
2424
HumanMessage(content="follow-up"),
2525
]
26-
assert count_successive_completions(messages) == 0
26+
assert count_consecutive_thinking_messages(messages) == 0
2727

2828
def test_ai_message_with_tool_calls(self):
2929
"""Should return 0 when last AI message has tool calls."""
@@ -34,23 +34,23 @@ def test_ai_message_with_tool_calls(self):
3434
tool_calls=[{"name": "test", "args": {}, "id": "call_1"}],
3535
),
3636
]
37-
assert count_successive_completions(messages) == 0
37+
assert count_consecutive_thinking_messages(messages) == 0
3838

3939
def test_ai_message_without_content(self):
4040
"""Should return 0 when last AI message has no content."""
4141
messages = [
4242
HumanMessage(content="query"),
4343
AIMessage(content=""),
4444
]
45-
assert count_successive_completions(messages) == 0
45+
assert count_consecutive_thinking_messages(messages) == 0
4646

4747
def test_single_text_completion(self):
4848
"""Should count single text-only AI message."""
4949
messages = [
5050
HumanMessage(content="query"),
5151
AIMessage(content="thinking"),
5252
]
53-
assert count_successive_completions(messages) == 1
53+
assert count_consecutive_thinking_messages(messages) == 1
5454

5555
def test_two_successive_completions(self):
5656
"""Should count multiple consecutive text-only AI messages."""
@@ -59,7 +59,7 @@ def test_two_successive_completions(self):
5959
AIMessage(content="thinking 1"),
6060
AIMessage(content="thinking 2"),
6161
]
62-
assert count_successive_completions(messages) == 2
62+
assert count_consecutive_thinking_messages(messages) == 2
6363

6464
def test_three_successive_completions(self):
6565
"""Should count all consecutive text-only AI messages at end."""
@@ -69,7 +69,7 @@ def test_three_successive_completions(self):
6969
AIMessage(content="thinking 2"),
7070
AIMessage(content="thinking 3"),
7171
]
72-
assert count_successive_completions(messages) == 3
72+
assert count_consecutive_thinking_messages(messages) == 3
7373

7474
def test_tool_call_resets_count(self):
7575
"""Should only count completions after last tool call."""
@@ -84,7 +84,7 @@ def test_tool_call_resets_count(self):
8484
AIMessage(content="thinking 2"),
8585
AIMessage(content="thinking 3"),
8686
]
87-
assert count_successive_completions(messages) == 2
87+
assert count_consecutive_thinking_messages(messages) == 2
8888

8989
def test_mixed_message_types(self):
9090
"""Should handle complex message patterns correctly."""
@@ -100,7 +100,7 @@ def test_mixed_message_types(self):
100100
HumanMessage(content="user follow-up"),
101101
AIMessage(content="responding to follow-up"),
102102
]
103-
assert count_successive_completions(messages) == 1
103+
assert count_consecutive_thinking_messages(messages) == 1
104104

105105
def test_multiple_tool_calls_in_message(self):
106106
"""Should reset count even with multiple tool calls."""
@@ -115,15 +115,15 @@ def test_multiple_tool_calls_in_message(self):
115115
],
116116
),
117117
]
118-
assert count_successive_completions(messages) == 0
118+
assert count_consecutive_thinking_messages(messages) == 0
119119

120120
def test_ai_message_with_empty_tool_calls_list(self):
121121
"""Should handle AI message with empty tool_calls list."""
122122
messages = [
123123
HumanMessage(content="query"),
124124
AIMessage(content="thinking", tool_calls=[]),
125125
]
126-
assert count_successive_completions(messages) == 1
126+
assert count_consecutive_thinking_messages(messages) == 1
127127

128128
def test_only_ai_messages_all_text(self):
129129
"""Should count all AI messages when all are text-only."""
@@ -132,4 +132,4 @@ def test_only_ai_messages_all_text(self):
132132
AIMessage(content="thought 2"),
133133
AIMessage(content="thought 3"),
134134
]
135-
assert count_successive_completions(messages) == 3
135+
assert count_consecutive_thinking_messages(messages) == 3

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)