Skip to content

Commit 99985f6

Browse files
feat: execute parallel tool calls sequentially
1 parent fe16c22 commit 99985f6

19 files changed

Lines changed: 869 additions & 267 deletions

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.4.10"
3+
version = "0.4.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"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from .exceptions import (
22
AgentNodeRoutingException,
3+
AgentStateException,
34
AgentTerminationException,
45
)
56

67
__all__ = [
78
"AgentNodeRoutingException",
9+
"AgentStateException",
810
"AgentTerminationException",
911
]

src/uipath_langchain/agent/exceptions/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ class AgentNodeRoutingException(Exception):
99

1010
class AgentTerminationException(UiPathRuntimeError):
1111
pass
12+
13+
14+
class AgentStateException(Exception):
15+
pass

src/uipath_langchain/agent/guardrails/actions/escalate_action.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
)
1616
from uipath.runtime.errors import UiPathErrorCode
1717

18-
from ...exceptions import AgentTerminationException
18+
from ...exceptions import AgentStateException, AgentTerminationException
1919
from ...react.types import AgentGuardrailsGraphState
20+
from ...react.utils import extract_current_tool_call_index, find_latest_ai_message
2021
from ..types import ExecutionStage
2122
from ..utils import _extract_tool_args_from_message, get_message_content
2223
from .base_action import GuardrailAction, GuardrailActionNode
@@ -420,9 +421,10 @@ def _process_tool_escalation_response(
420421
if not msgs or reviewed_field not in escalation_result:
421422
return {}
422423

423-
last_message = msgs[-1]
424424
if execution_stage == ExecutionStage.PRE_EXECUTION:
425-
if not isinstance(last_message, AIMessage):
425+
# Find the latest AI message instead of assuming last message is AI
426+
ai_message = find_latest_ai_message(msgs)
427+
if not ai_message:
426428
return {}
427429

428430
# Get reviewed tool calls args from escalation result
@@ -434,25 +436,40 @@ def _process_tool_escalation_response(
434436
if not isinstance(reviewed_tool_calls_args, dict):
435437
return {}
436438

437-
# Find and update only the tool call with matching name
438-
if last_message.tool_calls:
439-
tool_calls = list(last_message.tool_calls)
440-
for tool_call in tool_calls:
439+
# Find the current tool call index for the specific tool
440+
if ai_message.tool_calls:
441+
tool_calls = list(ai_message.tool_calls)
442+
current_index = extract_current_tool_call_index(msgs, tool_name)
443+
444+
# If we found the current index and it's valid
445+
if current_index is not None and current_index < len(tool_calls):
446+
tool_call = tool_calls[current_index]
441447
call_name = (
442448
tool_call.get("name")
443449
if isinstance(tool_call, dict)
444450
else getattr(tool_call, "name", None)
445451
)
452+
453+
# Verify this is the correct tool by name
446454
if call_name == tool_name:
447-
# Update args for the matching tool call
455+
# Update args for the specific tool call at current index
448456
if isinstance(reviewed_tool_calls_args, dict):
449457
if isinstance(tool_call, dict):
450458
tool_call["args"] = reviewed_tool_calls_args
451459
else:
452460
tool_call.args = reviewed_tool_calls_args
453-
break
454-
last_message.tool_calls = tool_calls
461+
462+
ai_message.tool_calls = tool_calls
463+
else:
464+
raise AgentStateException(
465+
f"Tool call name [{call_name}] does not match expected tool name [{tool_name}]."
466+
)
467+
else:
468+
return {}
469+
455470
else:
471+
# POST_EXECUTION: last message should be ToolMessage for tool escalation
472+
last_message = msgs[-1]
456473
if not isinstance(last_message, ToolMessage):
457474
return {}
458475

src/uipath_langchain/agent/guardrails/actions/filter_action.py

Lines changed: 46 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@
88
from uipath.runtime.errors import UiPathErrorCategory, UiPathErrorCode
99

1010
from uipath_langchain.agent.guardrails.types import ExecutionStage
11+
from uipath_langchain.agent.react.utils import (
12+
extract_current_tool_call_index,
13+
find_latest_ai_message,
14+
)
1115

12-
from ...exceptions import AgentTerminationException
16+
from ...exceptions import AgentStateException, AgentTerminationException
1317
from ...react.types import AgentGuardrailsGraphState
1418
from .base_action import GuardrailAction, GuardrailActionNode
1519

@@ -149,12 +153,9 @@ def _filter_tool_input_fields(
149153

150154
# Find the AIMessage with tool calls
151155
# At PRE_EXECUTION, this is always the last message
152-
ai_message = None
153-
for i in range(len(msgs) - 1, -1, -1):
154-
msg = msgs[i]
155-
if isinstance(msg, AIMessage) and msg.tool_calls:
156-
ai_message = msg
157-
break
156+
ai_message = find_latest_ai_message(msgs)
157+
if ai_message is None or not ai_message.tool_calls:
158+
return {}
158159

159160
if ai_message is None:
160161
return {}
@@ -165,40 +166,47 @@ def _filter_tool_input_fields(
165166
tool_calls = list(ai_message.tool_calls)
166167
modified = False
167168

168-
for tool_call in tool_calls:
169-
call_name = (
170-
tool_call.get("name")
169+
current_tool_call_index = extract_current_tool_call_index(msgs, tool_name)
170+
if current_tool_call_index is None:
171+
return {}
172+
173+
tool_call = tool_calls[current_tool_call_index]
174+
175+
call_name = (
176+
tool_call.get("name")
177+
if isinstance(tool_call, dict)
178+
else getattr(tool_call, "name", None)
179+
)
180+
181+
if call_name == tool_name:
182+
# Get the current args
183+
args = (
184+
tool_call.get("args")
171185
if isinstance(tool_call, dict)
172-
else getattr(tool_call, "name", None)
186+
else getattr(tool_call, "args", None)
173187
)
174188

175-
if call_name == tool_name:
176-
# Get the current args
177-
args = (
178-
tool_call.get("args")
179-
if isinstance(tool_call, dict)
180-
else getattr(tool_call, "args", None)
181-
)
182-
183-
if args and isinstance(args, dict):
184-
# Filter out the specified input fields
185-
filtered_args = args.copy()
186-
for field_ref in fields_to_filter:
187-
# Only filter input fields
188-
if (
189-
field_ref.source == FieldSource.INPUT
190-
and field_ref.path in filtered_args
191-
):
192-
del filtered_args[field_ref.path]
193-
modified = True
194-
195-
# Update the tool call with filtered args
196-
if isinstance(tool_call, dict):
197-
tool_call["args"] = filtered_args
198-
else:
199-
tool_call.args = filtered_args
200-
201-
break
189+
if args and isinstance(args, dict):
190+
# Filter out the specified input fields
191+
filtered_args = args.copy()
192+
for field_ref in fields_to_filter:
193+
# Only filter input fields
194+
if (
195+
field_ref.source == FieldSource.INPUT
196+
and field_ref.path in filtered_args
197+
):
198+
del filtered_args[field_ref.path]
199+
modified = True
200+
201+
# Update the tool call with filtered args
202+
if isinstance(tool_call, dict):
203+
tool_call["args"] = filtered_args
204+
else:
205+
tool_call.args = filtered_args
206+
else:
207+
raise AgentStateException(
208+
f"Tool call name [{call_name}] does not match expected tool name [{tool_name}]."
209+
)
202210

203211
if modified:
204212
ai_message.tool_calls = tool_calls

src/uipath_langchain/agent/react/agent.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,7 @@ def create_agent(
140140
)
141141

142142
for tool_name in tool_node_names:
143-
builder.add_edge(tool_name, AgentGraphNode.AGENT)
144-
143+
builder.add_conditional_edges(tool_name, route_agent, target_node_names)
145144
builder.add_edge(AgentGraphNode.TERMINATE, END)
146145

147146
return builder

src/uipath_langchain/agent/react/llm_node.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
from typing import Literal, Sequence
44

55
from langchain_core.language_models import BaseChatModel
6-
from langchain_core.messages import AIMessage, AnyMessage
6+
from langchain_core.messages import AIMessage, AnyMessage, ToolCall
77
from langchain_core.tools import BaseTool
88

99
from .constants import MAX_CONSECUTIVE_THINKING_MESSAGES
10-
from .types import AgentGraphState
10+
from .types import FLOW_CONTROL_TOOLS, AgentGraphState
1111
from .utils import count_consecutive_thinking_messages
1212

1313
OPENAI_COMPATIBLE_CHAT_MODELS = (
@@ -33,6 +33,16 @@ def _get_required_tool_choice_by_model(
3333
return "any"
3434

3535

36+
def _filter_control_flow_tool_calls(
37+
tool_calls: list[ToolCall],
38+
) -> list[ToolCall]:
39+
"""Remove control flow tools when multiple tool calls exist."""
40+
if len(tool_calls) <= 1:
41+
return tool_calls
42+
43+
return [tc for tc in tool_calls if tc.get("name") not in FLOW_CONTROL_TOOLS]
44+
45+
3646
def create_llm_node(
3747
model: BaseChatModel,
3848
tools: Sequence[BaseTool] | None = None,
@@ -74,6 +84,13 @@ async def llm_node(state: AgentGraphState):
7484
f"LLM returned {type(response).__name__} instead of AIMessage"
7585
)
7686

87+
# filter out flow control tools when multiple tool calls exist
88+
if response.tool_calls:
89+
filtered_tool_calls = _filter_control_flow_tool_calls(response.tool_calls)
90+
if len(filtered_tool_calls) != len(response.tool_calls):
91+
# todo: this does not actually work, but fixing tool call modifying is a separate task
92+
response.tool_calls = filtered_tool_calls
93+
7794
return {"messages": [response]}
7895

7996
return llm_node

src/uipath_langchain/agent/react/router.py

Lines changed: 45 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,13 @@
22

33
from typing import Literal
44

5-
from langchain_core.messages import ToolCall
6-
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
7-
85
from ..exceptions import AgentNodeRoutingException
9-
from .router_utils import validate_last_message_is_AI
10-
from .types import AgentGraphNode, AgentGraphState
11-
from .utils import count_consecutive_thinking_messages
12-
13-
FLOW_CONTROL_TOOLS = [END_EXECUTION_TOOL.name, RAISE_ERROR_TOOL.name]
14-
15-
16-
def __filter_control_flow_tool_calls(
17-
tool_calls: list[ToolCall],
18-
) -> list[ToolCall]:
19-
"""Remove control flow tools when multiple tool calls exist."""
20-
if len(tool_calls) <= 1:
21-
return tool_calls
22-
23-
return [tc for tc in tool_calls if tc.get("name") not in FLOW_CONTROL_TOOLS]
24-
25-
26-
def __has_control_flow_tool(tool_calls: list[ToolCall]) -> bool:
27-
"""Check if any tool call is of a control flow tool."""
28-
return any(tc.get("name") in FLOW_CONTROL_TOOLS for tc in tool_calls)
6+
from .types import FLOW_CONTROL_TOOLS, AgentGraphNode, AgentGraphState
7+
from .utils import (
8+
count_consecutive_thinking_messages,
9+
extract_current_tool_call_index,
10+
find_latest_ai_message,
11+
)
2912

3013

3114
def create_route_agent(thinking_messages_limit: int = 0):
@@ -40,50 +23,62 @@ def create_route_agent(thinking_messages_limit: int = 0):
4023

4124
def route_agent(
4225
state: AgentGraphState,
43-
) -> list[str] | Literal[AgentGraphNode.AGENT, AgentGraphNode.TERMINATE]:
44-
"""Route after agent: handles all routing logic including control flow detection.
26+
) -> str | Literal[AgentGraphNode.AGENT, AgentGraphNode.TERMINATE]:
27+
"""Route after agent: handles sequential tool execution.
4528
4629
Routing logic:
47-
1. If multiple tool calls exist, filter out control flow tools (EndExecution, RaiseError)
48-
2. If control flow tool(s) remain, route to TERMINATE
49-
3. If regular tool calls remain, route to specific tool nodes (return list of tool names)
50-
4. If no tool calls, handle consecutive completions
30+
1. Get current tool call index from messages
31+
2. If current tool call index is None (all tools completed), route to AGENT
32+
3. If current tool call is a flow control tool, route to TERMINATE
33+
4. Otherwise, route to the specific tool node
5134
5235
Returns:
53-
- list[str]: Tool node names for parallel execution
54-
- AgentGraphNode.AGENT: For consecutive completions
36+
- str: Single tool node name for sequential execution
37+
- AgentGraphNode.AGENT: When all tool calls completed or no tool calls
5538
- AgentGraphNode.TERMINATE: For control flow termination
5639
5740
Raises:
58-
AgentNodeRoutingException: When encountering unexpected state (empty messages, non-AIMessage, or excessive completions)
41+
AgentNodeRoutingException: When encountering unexpected state
5942
"""
6043
messages = state.messages
61-
last_message = validate_last_message_is_AI(messages)
62-
63-
tool_calls = list(last_message.tool_calls) if last_message.tool_calls else []
64-
tool_calls = __filter_control_flow_tool_calls(tool_calls)
44+
last_message = find_latest_ai_message(messages)
45+
if last_message is None:
46+
raise AgentNodeRoutingException(
47+
"No AIMessage found in messages for routing."
48+
)
6549

66-
if tool_calls and __has_control_flow_tool(tool_calls):
67-
return AgentGraphNode.TERMINATE
50+
if not last_message.tool_calls:
51+
consecutive_thinking_messages = count_consecutive_thinking_messages(
52+
messages
53+
)
6854

69-
if tool_calls:
70-
return [tc["name"] for tc in tool_calls]
55+
if consecutive_thinking_messages > thinking_messages_limit:
56+
raise AgentNodeRoutingException(
57+
f"Agent exceeded consecutive completions limit without producing tool calls "
58+
f"(completions: {consecutive_thinking_messages}, max: {thinking_messages_limit}). "
59+
f"This should not happen as tool_choice='required' is enforced at the limit."
60+
)
7161

72-
consecutive_thinking_messages = count_consecutive_thinking_messages(messages)
62+
if last_message.content:
63+
return AgentGraphNode.AGENT
7364

74-
if consecutive_thinking_messages > thinking_messages_limit:
7565
raise AgentNodeRoutingException(
76-
f"Agent exceeded consecutive completions limit without producing tool calls "
77-
f"(completions: {consecutive_thinking_messages}, max: {thinking_messages_limit}). "
78-
f"This should not happen as tool_choice='required' is enforced at the limit."
66+
f"Agent produced empty response without tool calls "
67+
f"(completions: {consecutive_thinking_messages}, has_content: False)"
7968
)
8069

81-
if last_message.content:
70+
current_index = extract_current_tool_call_index(messages)
71+
72+
# all tool calls completed, go back to agent
73+
if current_index is None:
8274
return AgentGraphNode.AGENT
8375

84-
raise AgentNodeRoutingException(
85-
f"Agent produced empty response without tool calls "
86-
f"(completions: {consecutive_thinking_messages}, has_content: False)"
87-
)
76+
current_tool_call = last_message.tool_calls[current_index]
77+
current_tool_name = current_tool_call["name"]
78+
79+
if current_tool_name in FLOW_CONTROL_TOOLS:
80+
return AgentGraphNode.TERMINATE
81+
82+
return current_tool_name
8883

8984
return route_agent

0 commit comments

Comments
 (0)