Skip to content

Commit fd78291

Browse files
feat: add create react agent
1 parent 3dfce38 commit fd78291

File tree

20 files changed

+731
-0
lines changed

20 files changed

+731
-0
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""UiPath ReAct Agent implementation"""
2+
3+
from .agent import create_agent
4+
from .state import AgentGraphNode, AgentGraphState
5+
from .utils import resolve_output_model
6+
7+
__all__ = [
8+
"create_agent",
9+
"AgentGraphState",
10+
"AgentGraphNode",
11+
"resolve_output_model",
12+
]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import os
2+
from typing import Sequence, Type
3+
4+
from langchain_core.language_models import BaseChatModel
5+
from langchain_core.messages import HumanMessage, SystemMessage
6+
from langchain_core.tools import BaseTool
7+
from langgraph.constants import END, START
8+
from langgraph.graph import StateGraph
9+
from pydantic import BaseModel
10+
11+
from ..tools import create_tool_node
12+
from .init_node import (
13+
create_init_node,
14+
)
15+
from .llm_node import (
16+
create_llm_node,
17+
)
18+
from .router import (
19+
route_agent,
20+
)
21+
from .state import AgentGraphNode, AgentGraphState
22+
from .terminate_node import (
23+
create_terminate_node,
24+
)
25+
from .tools import create_flow_control_tools
26+
27+
28+
def create_agent(
29+
model: BaseChatModel,
30+
tools: Sequence[BaseTool],
31+
messages: Sequence[SystemMessage | HumanMessage],
32+
*,
33+
state_schema: Type[AgentGraphState] = AgentGraphState,
34+
response_format: type[BaseModel] | None = None,
35+
recursion_limit: int = 50,
36+
) -> StateGraph[AgentGraphState]:
37+
"""Build agent graph with INIT -> AGENT <-> TOOLS loop, terminated by control flow tools.
38+
39+
Control flow tools (end_execution, raise_error) are auto-injected alongside regular tools.
40+
"""
41+
os.environ["LANGCHAIN_RECURSION_LIMIT"] = str(recursion_limit)
42+
43+
agent_tools = list(tools)
44+
flow_control_tools: list[BaseTool] = create_flow_control_tools(response_format)
45+
llm_tools: list[BaseTool] = [*agent_tools, *flow_control_tools]
46+
47+
init_node = create_init_node(messages)
48+
agent_node = create_llm_node(model, llm_tools)
49+
tool_nodes = create_tool_node(agent_tools)
50+
terminate_node = create_terminate_node(response_format)
51+
52+
builder: StateGraph[AgentGraphState] = StateGraph(state_schema)
53+
builder.add_node(AgentGraphNode.INIT, init_node)
54+
builder.add_node(AgentGraphNode.AGENT, agent_node)
55+
56+
for tool_name, tool_node in tool_nodes.items():
57+
builder.add_node(tool_name, tool_node)
58+
59+
builder.add_node(AgentGraphNode.TERMINATE, terminate_node)
60+
61+
builder.add_edge(START, AgentGraphNode.INIT)
62+
builder.add_edge(AgentGraphNode.INIT, AgentGraphNode.AGENT)
63+
64+
tool_node_names = list(tool_nodes.keys())
65+
builder.add_conditional_edges(
66+
AgentGraphNode.AGENT,
67+
route_agent,
68+
[AgentGraphNode.AGENT, *tool_node_names, AgentGraphNode.TERMINATE],
69+
)
70+
71+
for tool_name in tool_node_names:
72+
builder.add_edge(tool_name, AgentGraphNode.AGENT)
73+
74+
builder.add_edge(AgentGraphNode.TERMINATE, END)
75+
76+
return builder
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Agent routing configuration
2+
MAX_SUCCESSIVE_COMPLETIONS = 1
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Exceptions for the basic agent loop."""
2+
3+
from uipath._cli._runtime._contracts import UiPathRuntimeError
4+
5+
6+
class AgentNodeRoutingException(Exception):
7+
pass
8+
9+
10+
class AgentTerminationException(UiPathRuntimeError):
11+
pass
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""State initialization node for the ReAct Agent graph."""
2+
3+
from typing import Sequence
4+
5+
from langchain_core.messages import HumanMessage, SystemMessage
6+
7+
from .state import AgentGraphState
8+
9+
10+
def create_init_node(
11+
messages: Sequence[SystemMessage | HumanMessage],
12+
):
13+
def graph_state_init(_: AgentGraphState):
14+
return {"messages": list(messages)}
15+
16+
return graph_state_init
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""LLM node implementation for LangGraph."""
2+
3+
from typing import Sequence
4+
5+
from langchain_core.language_models import BaseChatModel
6+
from langchain_core.messages import AIMessage, AnyMessage
7+
from langchain_core.tools import BaseTool
8+
9+
from .constants import MAX_SUCCESSIVE_COMPLETIONS
10+
from .state import AgentGraphState
11+
from .utils import count_successive_completions
12+
13+
14+
def create_llm_node(
15+
model: BaseChatModel,
16+
tools: Sequence[BaseTool] | None = None,
17+
):
18+
"""Invoke LLM with tools and dynamically control tool_choice based on successive completions.
19+
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.
22+
"""
23+
bindable_tools = list(tools) if tools else []
24+
base_llm = model.bind_tools(bindable_tools) if bindable_tools else model
25+
26+
async def llm_node(state: AgentGraphState):
27+
messages: list[AnyMessage] = state["messages"]
28+
29+
successive_completions = count_successive_completions(messages)
30+
31+
if successive_completions >= MAX_SUCCESSIVE_COMPLETIONS and bindable_tools:
32+
llm = base_llm.bind(tool_choice="required")
33+
else:
34+
llm = base_llm
35+
36+
response = await llm.ainvoke(messages)
37+
if not isinstance(response, AIMessage):
38+
raise TypeError(
39+
f"LLM returned {type(response).__name__} instead of AIMessage"
40+
)
41+
42+
return {"messages": [response]}
43+
44+
return llm_node
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Routing functions for conditional edges in the agent graph."""
2+
3+
from typing import Literal
4+
5+
from langchain_core.messages import AIMessage, AnyMessage, ToolCall
6+
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
7+
8+
from .constants import MAX_SUCCESSIVE_COMPLETIONS
9+
from .exceptions import AgentNodeRoutingException
10+
from .state import AgentGraphNode, AgentGraphState
11+
from .utils import count_successive_completions
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)
29+
30+
31+
def __validate_last_message_is_AI(messages: list[AnyMessage]) -> AIMessage:
32+
"""Validate and return last message from state.
33+
34+
Raises:
35+
AgentNodeRoutingException: If messages are empty or last message is not AIMessage
36+
"""
37+
if not messages:
38+
raise AgentNodeRoutingException(
39+
"No messages in state - cannot route after agent"
40+
)
41+
42+
last_message = messages[-1]
43+
if not isinstance(last_message, AIMessage):
44+
raise AgentNodeRoutingException(
45+
f"Last message is not AIMessage (type: {type(last_message).__name__}) - cannot route after agent"
46+
)
47+
48+
return last_message
49+
50+
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.
55+
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
61+
62+
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)
69+
"""
70+
messages = state.get("messages", [])
71+
last_message = __validate_last_message_is_AI(messages)
72+
73+
tool_calls = list(last_message.tool_calls) if last_message.tool_calls else []
74+
tool_calls = __filter_control_flow_tool_calls(tool_calls)
75+
76+
if tool_calls and __has_control_flow_tool(tool_calls):
77+
return AgentGraphNode.TERMINATE
78+
79+
if tool_calls:
80+
return [tc["name"] for tc in tool_calls]
81+
82+
successive_completions = count_successive_completions(messages)
83+
84+
if successive_completions > MAX_SUCCESSIVE_COMPLETIONS:
85+
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."
89+
)
90+
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+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import annotations
2+
3+
from enum import StrEnum
4+
5+
from langgraph.graph import MessagesState
6+
7+
8+
class AgentGraphState(MessagesState):
9+
"""Agent Graph state for standard loop execution."""
10+
11+
pass
12+
13+
14+
class AgentGraphNode(StrEnum):
15+
INIT = "init"
16+
AGENT = "agent"
17+
TOOLS = "tools"
18+
TERMINATE = "terminate"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Termination node for the Agent graph."""
2+
3+
from __future__ import annotations
4+
5+
from langchain_core.messages import AIMessage
6+
from pydantic import BaseModel
7+
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
8+
9+
from .exceptions import (
10+
AgentNodeRoutingException,
11+
AgentTerminationException,
12+
)
13+
from .state import AgentGraphState
14+
15+
16+
def create_terminate_node(
17+
response_schema: type[BaseModel] | None = None,
18+
):
19+
"""Validates and returns end_execution args, or raises AgentTerminationException for raise_error."""
20+
21+
def terminate_node(state: AgentGraphState):
22+
last_message = state["messages"][-1]
23+
if not isinstance(last_message, AIMessage):
24+
raise AgentNodeRoutingException(
25+
f"Expected last message to be AIMessage, got {type(last_message).__name__}"
26+
)
27+
28+
for tool_call in last_message.tool_calls:
29+
tool_name = tool_call["name"]
30+
31+
if tool_name == END_EXECUTION_TOOL.name:
32+
args = tool_call["args"]
33+
output_schema = response_schema or END_EXECUTION_TOOL.args_schema
34+
validated = output_schema.model_validate(args)
35+
return validated.model_dump()
36+
37+
if tool_name == RAISE_ERROR_TOOL.name:
38+
error_message = tool_call["args"].get(
39+
"message", "The LLM did not set the error message"
40+
)
41+
detail = tool_call["args"].get("details", "")
42+
raise AgentTerminationException(
43+
code="400", title=error_message, detail=detail
44+
)
45+
46+
raise AgentNodeRoutingException(
47+
"No control flow tool call found in terminate node. Unexpected state."
48+
)
49+
50+
return terminate_node
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .tools import (
2+
create_flow_control_tools,
3+
)
4+
5+
__all__ = [
6+
"create_flow_control_tools",
7+
]

0 commit comments

Comments
 (0)