Skip to content

Commit 1a609a5

Browse files
refactor: remove agent termination graph specific handling (#423)
1 parent 8e5f36d commit 1a609a5

8 files changed

Lines changed: 51 additions & 111 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.4.13"
3+
version = "0.4.14"
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/terminate_node.py

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
AgentNodeRoutingException,
1414
AgentTerminationException,
1515
)
16-
from .types import AgentGraphState, AgentTermination
16+
from .types import AgentGraphState
1717

1818

1919
def _handle_end_execution(
@@ -36,30 +36,17 @@ def _handle_raise_error(args: dict[str, Any]) -> NoReturn:
3636
)
3737

3838

39-
def _handle_agent_termination(termination: AgentTermination) -> NoReturn:
40-
"""Handle Command-based termination."""
41-
raise AgentTerminationException(
42-
code=UiPathErrorCode.EXECUTION_ERROR,
43-
title=termination.title,
44-
detail=termination.detail,
45-
)
46-
47-
4839
def create_terminate_node(
4940
response_schema: type[BaseModel] | None = None, is_conversational: bool = False
5041
):
5142
"""Handles Agent Graph termination for multiple sources and output or error propagation to Orchestrator.
5243
5344
Termination scenarios:
54-
1. Command based termination with information in state (e.g: escalation)
55-
2. LLM-initiated termination (END_EXECUTION_TOOL)
56-
3. LLM-initiated error (RAISE_ERROR_TOOL)
45+
1. LLM-initiated termination (END_EXECUTION_TOOL)
46+
2. LLM-initiated error (RAISE_ERROR_TOOL)
5747
"""
5848

5949
def terminate_node(state: AgentGraphState):
60-
if state.inner_state.termination:
61-
_handle_agent_termination(state.inner_state.termination)
62-
6350
if not is_conversational:
6451
last_message = state.messages[-1]
6552
if not isinstance(last_message, AIMessage):

src/uipath_langchain/agent/react/types.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,8 @@
1212
FLOW_CONTROL_TOOLS = [END_EXECUTION_TOOL.name, RAISE_ERROR_TOOL.name]
1313

1414

15-
class AgentTerminationSource(StrEnum):
16-
ESCALATION = "escalation"
17-
18-
19-
class AgentTermination(BaseModel):
20-
"""Agent Graph Termination model."""
21-
22-
source: AgentTerminationSource
23-
title: str
24-
detail: str = ""
25-
26-
2715
class InnerAgentGraphState(BaseModel):
2816
job_attachments: Annotated[dict[str, Attachment], add_job_attachments] = {}
29-
termination: AgentTermination | None = None
3017

3118

3219
class InnerAgentGuardrailsGraphState(InnerAgentGraphState):

src/uipath_langchain/agent/tools/escalation_tool.py

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@
33
from enum import Enum
44
from typing import Any
55

6-
from langchain_core.messages import ToolMessage
76
from langchain_core.messages.tool import ToolCall
87
from langchain_core.tools import BaseTool, StructuredTool
9-
from langgraph.types import Command, interrupt
8+
from langgraph.types import interrupt
109
from uipath.agent.models.agent import (
1110
AgentEscalationChannel,
1211
AgentEscalationRecipient,
@@ -17,10 +16,11 @@
1716
from uipath.eval.mocks import mockable
1817
from uipath.platform import UiPath
1918
from uipath.platform.common import CreateEscalation
19+
from uipath.runtime.errors import UiPathErrorCode
2020

2121
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
2222

23-
from ..react.types import AgentGraphNode, AgentGraphState, AgentTerminationSource
23+
from ..exceptions import AgentTerminationException
2424
from .tool_node import ToolWrapperMixin
2525
from .utils import sanitize_tool_name
2626

@@ -132,8 +132,7 @@ async def escalation_tool_fn(**kwargs: Any) -> dict[str, Any]:
132132
async def escalation_wrapper(
133133
tool: BaseTool,
134134
call: ToolCall,
135-
state: AgentGraphState,
136-
) -> dict[str, Any] | Command[Any]:
135+
) -> dict[str, Any] | None:
137136
result = await tool.ainvoke(call["args"])
138137

139138
if result["action"] == EscalationAction.END:
@@ -143,23 +142,10 @@ async def escalation_wrapper(
143142
f"with directive {result['escalation_action']}"
144143
)
145144

146-
return Command(
147-
update={
148-
"messages": [
149-
ToolMessage(
150-
content=f"{termination_title}. {output_detail}",
151-
tool_call_id=call["id"],
152-
)
153-
],
154-
"inner_state": {
155-
"termination": {
156-
"source": AgentTerminationSource.ESCALATION,
157-
"title": termination_title,
158-
"detail": output_detail,
159-
}
160-
},
161-
},
162-
goto=AgentGraphNode.TERMINATE,
145+
raise AgentTerminationException(
146+
code=UiPathErrorCode.EXECUTION_ERROR,
147+
title=termination_title,
148+
detail=output_detail,
163149
)
164150

165151
return result["output"]

src/uipath_langchain/agent/tools/tool_node.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,28 @@
1919

2020
# the type safety can be improved with generics
2121
ToolWrapperReturnType = dict[str, Any] | Command[Any] | None
22-
ToolWrapperType = Callable[[BaseTool, ToolCall, Any], ToolWrapperReturnType]
23-
AsyncToolWrapperType = Callable[
22+
23+
ToolWrapperWithoutState = Callable[[BaseTool, ToolCall], ToolWrapperReturnType]
24+
ToolWrapperWithState = Callable[[BaseTool, ToolCall, Any], ToolWrapperReturnType]
25+
ToolWrapperType = ToolWrapperWithoutState | ToolWrapperWithState
26+
27+
AsyncToolWrapperWithoutState = Callable[
28+
[BaseTool, ToolCall], Awaitable[ToolWrapperReturnType]
29+
]
30+
AsyncToolWrapperWithState = Callable[
2431
[BaseTool, ToolCall, Any], Awaitable[ToolWrapperReturnType]
2532
]
33+
AsyncToolWrapperType = AsyncToolWrapperWithoutState | AsyncToolWrapperWithState
34+
2635
OutputType = dict[Literal["messages"], list[ToolMessage]] | Command[Any] | None
2736

2837

38+
def _wrapper_needs_state(wrapper: ToolWrapperType | AsyncToolWrapperType) -> bool:
39+
"""Check if wrapper function expects a state parameter."""
40+
params = list(signature(wrapper).parameters.values())
41+
return len(params) >= 3
42+
43+
2944
class UiPathToolNode(RunnableCallable):
3045
"""
3146
A ToolNode that can be used in a React agent graph.
@@ -57,8 +72,8 @@ def _func(self, state: AgentGraphState) -> OutputType:
5772
if call is None:
5873
return None
5974
if self.wrapper:
60-
filtered_state = self._filter_state(state, self.wrapper)
61-
result = self.wrapper(self.tool, call, filtered_state)
75+
inputs = self._prepare_wrapper_inputs(self.wrapper, self.tool, call, state)
76+
result = self.wrapper(*inputs)
6277
else:
6378
result = self.tool.invoke(call["args"])
6479
return self._process_result(call, result)
@@ -68,8 +83,8 @@ async def _afunc(self, state: AgentGraphState) -> OutputType:
6883
if call is None:
6984
return None
7085
if self.awrapper:
71-
filtered_state = self._filter_state(state, self.awrapper)
72-
result = await self.awrapper(self.tool, call, filtered_state)
86+
inputs = self._prepare_wrapper_inputs(self.awrapper, self.tool, call, state)
87+
result = await self.awrapper(*inputs)
7388
else:
7489
result = await self.tool.ainvoke(call["args"])
7590
return self._process_result(call, result)
@@ -106,6 +121,19 @@ def _process_result(
106121
)
107122
return {"messages": [message]}
108123

124+
def _prepare_wrapper_inputs(
125+
self,
126+
wrapper: ToolWrapperType | AsyncToolWrapperType,
127+
tool: BaseTool,
128+
call: ToolCall,
129+
state: AgentGraphState,
130+
) -> Sequence[Any]:
131+
"""Prepare inputs for wrapper invocation based on its signature."""
132+
if _wrapper_needs_state(wrapper):
133+
filtered_state = self._filter_state(state, wrapper)
134+
return tool, call, filtered_state
135+
return tool, call
136+
109137
def _filter_state(
110138
self, state: Any, wrapper: ToolWrapperType | AsyncToolWrapperType
111139
) -> BaseModel:
@@ -137,7 +165,6 @@ def create_tool_node(tools: Sequence[BaseTool]) -> dict[str, UiPathToolNode]:
137165
138166
Args:
139167
tools: Sequence of tools to create nodes for.
140-
agentState: The type of the agent state model.
141168
142169
Returns:
143170
Dict mapping tool.name -> ReactToolNode([tool]).

src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
replace_job_attachment_ids,
1212
)
1313
from uipath_langchain.agent.react.types import AgentGraphState
14-
from uipath_langchain.agent.tools.tool_node import AsyncToolWrapperType
14+
from uipath_langchain.agent.tools.tool_node import AsyncToolWrapperWithState
1515

1616

17-
def get_job_attachment_wrapper(output_type: Any | None = None) -> AsyncToolWrapperType:
17+
def get_job_attachment_wrapper(
18+
output_type: Any | None = None,
19+
) -> AsyncToolWrapperWithState:
1820
"""Create a tool wrapper that handles job attachments in both tool inputs and outputs.
1921
2022
This wrapper performs two main functions:

tests/agent/react/test_terminate_node.py

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,11 @@
1212
AgentTerminationException,
1313
)
1414
from uipath_langchain.agent.react.terminate_node import create_terminate_node
15-
from uipath_langchain.agent.react.types import (
16-
AgentTermination,
17-
AgentTerminationSource,
18-
)
1915

2016

2117
class MockInnerState(BaseModel):
2218
"""Mock inner state for testing."""
2319

24-
termination: AgentTermination | None = None
2520
job_attachments: dict[str, Any] = {}
2621

2722

@@ -52,19 +47,6 @@ def state_with_human_message(self):
5247
"""Fixture for state with human message as last."""
5348
return MockAgentGraphState(messages=[HumanMessage(content="User message")])
5449

55-
@pytest.fixture
56-
def state_with_termination(self):
57-
"""Fixture for state with termination set (e.g., escalation)."""
58-
termination = AgentTermination(
59-
source=AgentTerminationSource.ESCALATION,
60-
title="Escalation required",
61-
detail="User requested human assistance",
62-
)
63-
return MockAgentGraphState(
64-
messages=[AIMessage(content="response")],
65-
inner_state=MockInnerState(termination=termination),
66-
)
67-
6850
def test_conversational_returns_none_no_tool_calls(
6951
self, terminate_node, state_with_ai_message
7052
):
@@ -82,15 +64,6 @@ def test_conversational_skips_ai_message_validation(
8264

8365
assert result is None
8466

85-
def test_conversational_handles_termination(
86-
self, terminate_node, state_with_termination
87-
):
88-
"""Conversational mode should still handle state.inner_state.termination."""
89-
with pytest.raises(AgentTerminationException) as exc_info:
90-
terminate_node(state_with_termination)
91-
92-
assert "Escalation required" in exc_info.value.error_info.title
93-
9467
def test_conversational_ignores_end_execution_tool(self):
9568
"""Conversational mode should ignore END_EXECUTION tool calls."""
9669
terminate_node = create_terminate_node(
@@ -169,19 +142,6 @@ def state_with_no_control_flow_tool(self):
169142
)
170143
return MockAgentGraphState(messages=[ai_message])
171144

172-
@pytest.fixture
173-
def state_with_termination(self):
174-
"""Fixture for state with escalation termination."""
175-
termination = AgentTermination(
176-
source=AgentTerminationSource.ESCALATION,
177-
title="Needs human review",
178-
detail="Complex issue",
179-
)
180-
return MockAgentGraphState(
181-
messages=[AIMessage(content="response")],
182-
inner_state=MockInnerState(termination=termination),
183-
)
184-
185145
def test_non_conversational_handles_end_execution(
186146
self, terminate_node, state_with_end_execution
187147
):
@@ -223,15 +183,6 @@ def test_non_conversational_raises_on_no_control_flow_tool(
223183
):
224184
terminate_node(state_with_no_control_flow_tool)
225185

226-
def test_non_conversational_handles_termination_first(
227-
self, terminate_node, state_with_termination
228-
):
229-
"""Non-conversational mode should check termination before tool calls."""
230-
with pytest.raises(AgentTerminationException) as exc_info:
231-
terminate_node(state_with_termination)
232-
233-
assert "Needs human review" in exc_info.value.error_info.title
234-
235186

236187
class TestTerminateNodeWithResponseSchema:
237188
"""Test cases for terminate node with custom response schema."""

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)