Skip to content

Commit 517f520

Browse files
feat: support sequential parallel tool calls
1 parent 071863c commit 517f520

File tree

10 files changed

+460
-99
lines changed

10 files changed

+460
-99
lines changed

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.2.0"
3+
version = "0.2.1"
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: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from ..guardrails.actions import GuardrailAction
1313
from ..tools import create_tool_node
14+
from ..tools.orchestrator_node import create_orchestrator_node
1415
from .guardrails.guardrails_subgraph import (
1516
create_agent_init_guardrails_subgraph,
1617
create_agent_terminate_guardrails_subgraph,
@@ -105,6 +106,9 @@ def create_agent(
105106
)
106107
builder.add_node(AgentGraphNode.TERMINATE, terminate_with_guardrails_subgraph)
107108

109+
orchestrator_node = create_orchestrator_node(config.thinking_messages_limit)
110+
builder.add_node(AgentGraphNode.ORCHESTRATOR, orchestrator_node)
111+
108112
builder.add_edge(START, AgentGraphNode.INIT)
109113

110114
llm_node = create_llm_node(model, llm_tools, config.thinking_messages_limit)
@@ -114,16 +118,19 @@ def create_agent(
114118
builder.add_node(AgentGraphNode.AGENT, llm_with_guardrails_subgraph)
115119
builder.add_edge(AgentGraphNode.INIT, AgentGraphNode.AGENT)
116120

121+
builder.add_edge(AgentGraphNode.AGENT, AgentGraphNode.ORCHESTRATOR)
122+
117123
tool_node_names = list(tool_nodes_with_guardrails.keys())
118-
route_agent = create_route_agent(config.thinking_messages_limit)
124+
route_agent = create_route_agent()
125+
119126
builder.add_conditional_edges(
120-
AgentGraphNode.AGENT,
127+
AgentGraphNode.ORCHESTRATOR,
121128
route_agent,
122129
[AgentGraphNode.AGENT, *tool_node_names, AgentGraphNode.TERMINATE],
123130
)
124131

125132
for tool_name in tool_node_names:
126-
builder.add_edge(tool_name, AgentGraphNode.AGENT)
133+
builder.add_edge(tool_name, AgentGraphNode.ORCHESTRATOR)
127134

128135
builder.add_edge(AgentGraphNode.TERMINATE, END)
129136

src/uipath_langchain/agent/react/router.py

Lines changed: 43 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2,107 +2,76 @@
22

33
from typing import Literal
44

5-
from langchain_core.messages import AIMessage, AnyMessage, ToolCall
6-
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
7-
85
from ..exceptions import AgentNodeRoutingException
9-
from .types import AgentGraphNode, AgentGraphState
10-
from .utils import count_consecutive_thinking_messages
11-
12-
FLOW_CONTROL_TOOLS = [END_EXECUTION_TOOL.name, RAISE_ERROR_TOOL.name]
13-
14-
15-
def __filter_control_flow_tool_calls(
16-
tool_calls: list[ToolCall],
17-
) -> list[ToolCall]:
18-
"""Remove control flow tools when multiple tool calls exist."""
19-
if len(tool_calls) <= 1:
20-
return tool_calls
21-
22-
return [tc for tc in tool_calls if tc.get("name") not in FLOW_CONTROL_TOOLS]
23-
24-
25-
def __has_control_flow_tool(tool_calls: list[ToolCall]) -> bool:
26-
"""Check if any tool call is of a control flow tool."""
27-
return any(tc.get("name") in FLOW_CONTROL_TOOLS for tc in tool_calls)
28-
29-
30-
def __validate_last_message_is_AI(messages: list[AnyMessage]) -> AIMessage:
31-
"""Validate and return last message from state.
32-
33-
Raises:
34-
AgentNodeRoutingException: If messages are empty or last message is not AIMessage
35-
"""
36-
if not messages:
37-
raise AgentNodeRoutingException(
38-
"No messages in state - cannot route after agent"
39-
)
40-
41-
last_message = messages[-1]
42-
if not isinstance(last_message, AIMessage):
43-
raise AgentNodeRoutingException(
44-
f"Last message is not AIMessage (type: {type(last_message).__name__}) - cannot route after agent"
45-
)
46-
47-
return last_message
6+
from .types import (
7+
FLOW_CONTROL_TOOLS,
8+
AgentGraphNode,
9+
AgentGraphState,
10+
)
11+
from .utils import find_latest_ai_message
4812

4913

50-
def create_route_agent(thinking_messages_limit: int = 0):
51-
"""Create a routing function configured with thinking_messages_limit.
52-
53-
Args:
54-
thinking_messages_limit: Max consecutive thinking messages before error
14+
def create_route_agent():
15+
"""Create a routing function for LangGraph conditional edges.
5516
5617
Returns:
5718
Routing function for LangGraph conditional edges
5819
"""
5920

6021
def route_agent(
6122
state: AgentGraphState,
62-
) -> list[str] | Literal[AgentGraphNode.AGENT, AgentGraphNode.TERMINATE]:
63-
"""Route after agent: handles all routing logic including control flow detection.
23+
) -> str | Literal[AgentGraphNode.AGENT, AgentGraphNode.TERMINATE]:
24+
"""Route after agent: looks at current tool call index and routes to corresponding tool node.
6425
6526
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
27+
1. If current_tool_call_index is None, route back to LLM
28+
2. If current_tool_call_index is set, route to the corresponding tool node
29+
3. Handle control flow tools for termination
7030
7131
Returns:
72-
- list[str]: Tool node names for parallel execution
73-
- AgentGraphNode.AGENT: For consecutive completions
32+
- str: Tool node name for single tool execution
33+
- AgentGraphNode.AGENT: When no current tool call index
7434
- AgentGraphNode.TERMINATE: For control flow termination
7535
7636
Raises:
77-
AgentNodeRoutingException: When encountering unexpected state (empty messages, non-AIMessage, or excessive completions)
37+
AgentNodeRoutingException: When encountering unexpected state
7838
"""
39+
current_index = state.current_tool_call_index
40+
41+
# no tool call in progress, route back to LLM
42+
if current_index is None:
43+
return AgentGraphNode.AGENT
44+
7945
messages = state.messages
80-
last_message = __validate_last_message_is_AI(messages)
8146

82-
tool_calls = list(last_message.tool_calls) if last_message.tool_calls else []
83-
tool_calls = __filter_control_flow_tool_calls(tool_calls)
47+
if not messages:
48+
raise AgentNodeRoutingException(
49+
"No messages in state - cannot route after agent"
50+
)
8451

85-
if tool_calls and __has_control_flow_tool(tool_calls):
86-
return AgentGraphNode.TERMINATE
52+
latest_ai_message = find_latest_ai_message(messages)
8753

88-
if tool_calls:
89-
return [tc["name"] for tc in tool_calls]
54+
if latest_ai_message is None:
55+
raise AgentNodeRoutingException(
56+
"No AIMessage found in messages - cannot route after agent"
57+
)
9058

91-
consecutive_thinking_messages = count_consecutive_thinking_messages(messages)
59+
tool_calls = (
60+
list(latest_ai_message.tool_calls) if latest_ai_message.tool_calls else []
61+
)
9262

93-
if consecutive_thinking_messages > thinking_messages_limit:
63+
if current_index >= len(tool_calls):
9464
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."
65+
f"Current tool call index {current_index} exceeds available tool calls ({len(tool_calls)})"
9866
)
9967

100-
if last_message.content:
101-
return AgentGraphNode.AGENT
68+
current_tool_call = tool_calls[current_index]
69+
tool_name = current_tool_call["name"]
10270

103-
raise AgentNodeRoutingException(
104-
f"Agent produced empty response without tool calls "
105-
f"(completions: {consecutive_thinking_messages}, has_content: False)"
106-
)
71+
# handle control flow tools for termination
72+
if tool_name in FLOW_CONTROL_TOOLS:
73+
return AgentGraphNode.TERMINATE
74+
75+
return tool_name
10776

10877
return route_agent

src/uipath_langchain/agent/react/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
from langchain_core.messages import AnyMessage
55
from langgraph.graph.message import add_messages
66
from pydantic import BaseModel, Field
7+
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
78
from uipath.platform.attachments import Attachment
89

910
from uipath_langchain.agent.react.utils import add_job_attachments
1011

12+
FLOW_CONTROL_TOOLS = [END_EXECUTION_TOOL.name, RAISE_ERROR_TOOL.name]
13+
1114

1215
class AgentTerminationSource(StrEnum):
1316
ESCALATION = "escalation"
@@ -27,6 +30,7 @@ class AgentGraphState(BaseModel):
2730
messages: Annotated[list[AnyMessage], add_messages] = []
2831
job_attachments: Annotated[dict[str, Attachment], add_job_attachments] = {}
2932
termination: AgentTermination | None = None
33+
current_tool_call_index: int | None = None
3034

3135

3236
class AgentGuardrailsGraphState(AgentGraphState):
@@ -41,6 +45,7 @@ class AgentGraphNode(StrEnum):
4145
GUARDED_INIT = "guarded-init"
4246
AGENT = "agent"
4347
LLM = "llm"
48+
ORCHESTRATOR = "orchestrator"
4449
TOOLS = "tools"
4550
TERMINATE = "terminate"
4651
GUARDED_TERMINATE = "guarded-terminate"

src/uipath_langchain/agent/react/utils.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import Any, Sequence
44

5-
from langchain_core.messages import AIMessage, BaseMessage
5+
from langchain_core.messages import AIMessage, AnyMessage, BaseMessage
66
from pydantic import BaseModel
77
from uipath.agent.react import END_EXECUTION_TOOL
88
from uipath.platform.attachments import Attachment
@@ -73,3 +73,18 @@ def add_job_attachments(
7373
return right
7474

7575
return {**left, **right}
76+
77+
78+
def find_latest_ai_message(messages: list[AnyMessage]) -> AIMessage | None:
79+
"""Find and return the latest AIMessage from a list of messages.
80+
81+
Args:
82+
messages: List of messages to search through
83+
84+
Returns:
85+
The latest AIMessage found, or None if no AIMessage exists
86+
"""
87+
for message in reversed(messages):
88+
if isinstance(message, AIMessage):
89+
return message
90+
return None
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from typing import Any
2+
3+
from langchain_core.messages import ToolCall
4+
5+
from uipath_langchain.agent.exceptions import AgentNodeRoutingException
6+
from uipath_langchain.agent.react.types import FLOW_CONTROL_TOOLS, AgentGraphState
7+
from uipath_langchain.agent.react.utils import (
8+
count_consecutive_thinking_messages,
9+
find_latest_ai_message,
10+
)
11+
12+
13+
def __filter_control_flow_tool_calls(tool_calls: list[ToolCall]) -> list[ToolCall]:
14+
"""Remove control flow tools when multiple tool calls exist."""
15+
if len(tool_calls) <= 1:
16+
return tool_calls
17+
18+
return [tc for tc in tool_calls if tc.get("name") not in FLOW_CONTROL_TOOLS]
19+
20+
21+
def create_orchestrator_node(thinking_messages_limit: int = 0):
22+
"""Create an orchestrator node responsible for sequencing tool calls.
23+
24+
Args:
25+
thinking_messages_limit: Max consecutive thinking messages before error
26+
"""
27+
28+
def orchestrator_node(state: AgentGraphState) -> dict[str, Any]:
29+
current_index = state.current_tool_call_index
30+
31+
if current_index is None:
32+
# new batch of tool calls
33+
if not state.messages:
34+
raise AgentNodeRoutingException(
35+
"No messages in state - cannot process tool calls"
36+
)
37+
38+
# check consecutive thinking messages limit
39+
if thinking_messages_limit >= 0:
40+
consecutive_thinking = count_consecutive_thinking_messages(
41+
state.messages
42+
)
43+
if consecutive_thinking > thinking_messages_limit:
44+
raise AgentNodeRoutingException(
45+
f"Too many consecutive thinking messages ({consecutive_thinking}). "
46+
f"Limit is {thinking_messages_limit}. Agent must use tools."
47+
)
48+
49+
latest_ai_message = find_latest_ai_message(state.messages)
50+
51+
if latest_ai_message is None or not latest_ai_message.tool_calls:
52+
return {"current_tool_call_index": None}
53+
54+
# apply flow control tool filtering
55+
original_tool_calls = list(latest_ai_message.tool_calls)
56+
filtered_tool_calls = __filter_control_flow_tool_calls(original_tool_calls)
57+
58+
if len(filtered_tool_calls) != len(original_tool_calls):
59+
modified_message = latest_ai_message.model_copy()
60+
modified_message.tool_calls = filtered_tool_calls
61+
62+
# we need to filter out the content within the message as well, otherwise the LLM will raise an error
63+
filtered_tool_call_ids = {tc["id"] for tc in filtered_tool_calls}
64+
if isinstance(modified_message.content, list):
65+
modified_message.content = [
66+
block
67+
for block in modified_message.content
68+
if (
69+
isinstance(block, dict)
70+
and (
71+
block.get("call_id") in filtered_tool_call_ids
72+
or block.get("call_id") is None # keep non-tool blocks
73+
)
74+
)
75+
or not isinstance(block, dict)
76+
]
77+
78+
return {
79+
"current_tool_call_index": 0,
80+
"messages": [modified_message],
81+
}
82+
83+
return {"current_tool_call_index": 0}
84+
85+
# in the middle of processing a batch
86+
if not state.messages:
87+
raise AgentNodeRoutingException(
88+
"No messages in state during batch processing"
89+
)
90+
91+
latest_ai_message = find_latest_ai_message(state.messages)
92+
93+
if latest_ai_message is None:
94+
raise AgentNodeRoutingException(
95+
"No AI message found during batch processing"
96+
)
97+
98+
if not latest_ai_message.tool_calls:
99+
raise AgentNodeRoutingException(
100+
"No tool calls found in AI message during batch processing"
101+
)
102+
103+
total_tool_calls = len(latest_ai_message.tool_calls)
104+
next_index = current_index + 1
105+
106+
if next_index >= total_tool_calls:
107+
return {"current_tool_call_index": None}
108+
else:
109+
return {"current_tool_call_index": next_index}
110+
111+
return orchestrator_node

0 commit comments

Comments
 (0)