Skip to content

Commit ee34d32

Browse files
fix: multiple control flow tools same llm call (#673)
1 parent a1ad5e3 commit ee34d32

4 files changed

Lines changed: 97 additions & 4 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.8.10"
3+
version = "0.8.11"
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/llm_node.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
)
1111
from langchain_core.tools import BaseTool, StructuredTool
1212
from pydantic import BaseModel
13+
from uipath.agent.react import RAISE_ERROR_TOOL
1314
from uipath.runtime.errors import UiPathErrorCategory
1415

1516
from uipath_langchain.agent.tools.static_args import (
@@ -30,11 +31,24 @@
3031
def _filter_control_flow_tool_calls(
3132
tool_calls: list[ToolCall],
3233
) -> list[ToolCall]:
33-
"""Remove control flow tools when multiple tool calls exist."""
34+
"""Remove control flow tool calls only when regular tool calls exist alongside them.
35+
36+
When only control flow tool calls are present and raise_error is among them,
37+
keep only the first raise_error (takes precedence over end_execution).
38+
"""
3439
if len(tool_calls) <= 1:
3540
return tool_calls
3641

37-
return [tc for tc in tool_calls if tc.get("name") not in FLOW_CONTROL_TOOLS]
42+
non_control_flow_tool_calls = [
43+
tc for tc in tool_calls if tc.get("name") not in FLOW_CONTROL_TOOLS
44+
]
45+
if not non_control_flow_tool_calls:
46+
raise_error_calls = [
47+
tc for tc in tool_calls if tc.get("name") == RAISE_ERROR_TOOL.name
48+
]
49+
return raise_error_calls[:1] if raise_error_calls else tool_calls
50+
51+
return non_control_flow_tool_calls
3852

3953

4054
StateT = TypeVar("StateT", bound=AgentGraphState)

tests/agent/react/test_llm_node.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,82 @@ async def test_multiple_flow_control_calls_all_filtered(self):
263263
]
264264
assert len(tool_call_blocks) == 1
265265
assert tool_call_blocks[0]["name"] == "regular_tool"
266+
267+
@pytest.mark.asyncio
268+
async def test_end_execution_and_raise_error_keeps_only_raise_error(self):
269+
"""When only end_execution and raise_error are called, keep only raise_error."""
270+
mock_response = AIMessage(
271+
content_blocks=[
272+
create_tool_call(
273+
name=END_EXECUTION_TOOL.name,
274+
args={"result": "done"},
275+
id="call_1",
276+
),
277+
create_tool_call(
278+
name=RAISE_ERROR_TOOL.name,
279+
args={"message": "conflict"},
280+
id="call_2",
281+
),
282+
],
283+
tool_calls=[
284+
{
285+
"name": END_EXECUTION_TOOL.name,
286+
"args": {"result": "done"},
287+
"id": "call_1",
288+
},
289+
{
290+
"name": RAISE_ERROR_TOOL.name,
291+
"args": {"message": "conflict"},
292+
"id": "call_2",
293+
},
294+
],
295+
)
296+
self.mock_model.ainvoke = AsyncMock(return_value=mock_response)
297+
298+
llm_node = create_llm_node(self.mock_model, [self.regular_tool])
299+
300+
result = await llm_node(self.test_state)
301+
302+
response_message = result["messages"][0]
303+
assert len(response_message.tool_calls) == 1
304+
assert response_message.tool_calls[0]["name"] == RAISE_ERROR_TOOL.name
305+
306+
@pytest.mark.asyncio
307+
async def test_multiple_raise_error_calls_keeps_only_first(self):
308+
"""When raise_error is called multiple times, keep only the first."""
309+
mock_response = AIMessage(
310+
content_blocks=[
311+
create_tool_call(
312+
name=RAISE_ERROR_TOOL.name,
313+
args={"message": "first error"},
314+
id="call_1",
315+
),
316+
create_tool_call(
317+
name=RAISE_ERROR_TOOL.name,
318+
args={"message": "second error"},
319+
id="call_2",
320+
),
321+
],
322+
tool_calls=[
323+
{
324+
"name": RAISE_ERROR_TOOL.name,
325+
"args": {"message": "first error"},
326+
"id": "call_1",
327+
},
328+
{
329+
"name": RAISE_ERROR_TOOL.name,
330+
"args": {"message": "second error"},
331+
"id": "call_2",
332+
},
333+
],
334+
)
335+
self.mock_model.ainvoke = AsyncMock(return_value=mock_response)
336+
337+
llm_node = create_llm_node(self.mock_model, [self.regular_tool])
338+
339+
result = await llm_node(self.test_state)
340+
341+
response_message = result["messages"][0]
342+
assert len(response_message.tool_calls) == 1
343+
assert response_message.tool_calls[0]["name"] == RAISE_ERROR_TOOL.name
344+
assert response_message.tool_calls[0]["args"]["message"] == "first error"

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)