Skip to content

Commit c793e04

Browse files
authored
fix: catch conversational-agent tool-execution errors as error result (#633)
1 parent 16850be commit c793e04

7 files changed

Lines changed: 215 additions & 18 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.7.4"
3+
version = "0.7.5"
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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ def create_agent(
7676

7777
init_node = create_init_node(messages, input_schema, config.is_conversational)
7878

79-
tool_nodes = create_tool_node(agent_tools)
79+
tool_nodes = create_tool_node(
80+
agent_tools, handle_tool_errors=config.is_conversational
81+
)
8082

8183
# for conversational agents we transform deeprag's citation format into cas's
8284
if config.is_conversational:

src/uipath_langchain/agent/tools/tool_node.py

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,33 +66,51 @@ def __init__(
6666
tool: BaseTool,
6767
wrapper: ToolWrapperType | None = None,
6868
awrapper: AsyncToolWrapperType | None = None,
69+
handle_tool_errors: bool = False,
6970
):
7071
super().__init__(func=self._func, afunc=self._afunc, name=tool.name)
7172
self.tool = tool
7273
self.wrapper = wrapper
7374
self.awrapper = awrapper
75+
self.handle_tool_errors = handle_tool_errors
7476

7577
def _func(self, state: AgentGraphState) -> OutputType:
7678
call = self._extract_tool_call(state)
7779
if call is None:
7880
return None
79-
if self.wrapper:
80-
inputs = self._prepare_wrapper_inputs(self.wrapper, self.tool, call, state)
81-
result = self.wrapper(*inputs)
82-
else:
83-
result = self.tool.invoke(call)
84-
return self._process_result(call, result)
81+
82+
try:
83+
if self.wrapper:
84+
inputs = self._prepare_wrapper_inputs(
85+
self.wrapper, self.tool, call, state
86+
)
87+
result = self.wrapper(*inputs)
88+
else:
89+
result = self.tool.invoke(call)
90+
return self._process_result(call, result)
91+
except Exception as e:
92+
if self.handle_tool_errors:
93+
return self._process_error_result(call, e)
94+
raise
8595

8696
async def _afunc(self, state: AgentGraphState) -> OutputType:
8797
call = self._extract_tool_call(state)
8898
if call is None:
8999
return None
90-
if self.awrapper:
91-
inputs = self._prepare_wrapper_inputs(self.awrapper, self.tool, call, state)
92-
result = await self.awrapper(*inputs)
93-
else:
94-
result = await self.tool.ainvoke(call)
95-
return self._process_result(call, result)
100+
101+
try:
102+
if self.awrapper:
103+
inputs = self._prepare_wrapper_inputs(
104+
self.awrapper, self.tool, call, state
105+
)
106+
result = await self.awrapper(*inputs)
107+
else:
108+
result = await self.tool.ainvoke(call)
109+
return self._process_result(call, result)
110+
except Exception as e:
111+
if self.handle_tool_errors:
112+
return self._process_error_result(call, e)
113+
raise
96114

97115
def _extract_tool_call(self, state: AgentGraphState) -> ToolCall | None:
98116
"""Extract the tool call from the state messages."""
@@ -114,6 +132,16 @@ def _extract_tool_call(self, state: AgentGraphState) -> ToolCall | None:
114132

115133
return latest_ai_message.tool_calls[current_tool_call_index]
116134

135+
def _process_error_result(self, call: ToolCall, error: Exception) -> OutputType:
136+
"""Handle tool execution errors by creating an error ToolMessage."""
137+
error_message = ToolMessage(
138+
content=str(error),
139+
name=call["name"],
140+
tool_call_id=call["id"],
141+
status="error",
142+
)
143+
return {"messages": [error_message]}
144+
117145
def _process_result(
118146
self, call: ToolCall, result: dict[str, Any] | Command[Any] | ToolMessage | None
119147
) -> OutputType:
@@ -189,25 +217,35 @@ def set_tool_wrappers(
189217
self.awrapper = awrapper
190218

191219

192-
def create_tool_node(tools: Sequence[BaseTool]) -> dict[str, UiPathToolNode]:
220+
def create_tool_node(
221+
tools: Sequence[BaseTool], handle_tool_errors: bool = False
222+
) -> dict[str, UiPathToolNode]:
193223
"""Create individual ToolNode for each tool.
194224
195225
Args:
196226
tools: Sequence of tools to create nodes for.
227+
handle_tool_errors: If True, catch tool execution errors and return them as error ToolMessages
228+
instead of letting exceptions propagate.
197229
198230
Returns:
199231
Dict mapping tool.name -> ReactToolNode([tool]).
200232
Each tool gets its own dedicated node for middleware composition.
201233
202234
Note:
203235
handle_tool_errors=False delegates error handling to LangGraph's error boundary.
236+
handle_tool_errors=True will cause errors to be caught and converted to ToolMessages with status="error".
204237
"""
205238
dict_mapping: dict[str, UiPathToolNode] = {}
206239
for tool in tools:
207240
if isinstance(tool, ToolWrapperMixin):
208241
dict_mapping[tool.name] = UiPathToolNode(
209-
tool, wrapper=tool.wrapper, awrapper=tool.awrapper
242+
tool,
243+
wrapper=tool.wrapper,
244+
awrapper=tool.awrapper,
245+
handle_tool_errors=handle_tool_errors,
210246
)
211247
else:
212-
dict_mapping[tool.name] = UiPathToolNode(tool, wrapper=None, awrapper=None)
248+
dict_mapping[tool.name] = UiPathToolNode(
249+
tool, wrapper=None, awrapper=None, handle_tool_errors=handle_tool_errors
250+
)
213251
return dict_mapping

src/uipath_langchain/runtime/messages.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ async def map_tool_message_to_events(
434434
end=UiPathConversationToolCallEndEvent(
435435
timestamp=self.get_timestamp(),
436436
output=content_value,
437+
is_error=message.status == "error",
437438
),
438439
),
439440
)

tests/agent/tools/test_tool_node.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ async def _arun(self, input_text: str = "") -> str:
4646
return f"Async wrapped mock result: {input_text}"
4747

4848

49+
class MockFailingTool(BaseTool):
50+
"""Mock tool that always fails for testing error handling."""
51+
52+
name: str = "mock_failing_tool"
53+
description: str = "A mock tool that fails for testing"
54+
55+
def _run(self, input_text: str = "") -> str:
56+
raise ValueError(f"Tool execution failed: {input_text}")
57+
58+
async def _arun(self, input_text: str = "") -> str:
59+
raise ValueError(f"Async tool execution failed: {input_text}")
60+
61+
4962
class FilteredState(BaseModel):
5063
"""Mock filtered state model for testing wrappers."""
5164

@@ -283,6 +296,96 @@ def invalid_wrapper(
283296
AgentRuntimeErrorCode.TOOL_INVALID_WRAPPER_STATE
284297
)
285298

299+
def test_tool_error_propagates_when_handle_errors_false(self, mock_state):
300+
"""Test that tool errors propagate when handle_tool_errors=False."""
301+
failing_tool = MockFailingTool()
302+
tool_call = {
303+
"name": "mock_failing_tool",
304+
"args": {"input_text": "test input"},
305+
"id": "test_call_id",
306+
}
307+
ai_message = AIMessage(content="Using tool", tool_calls=[tool_call])
308+
state = MockState(messages=[ai_message])
309+
310+
node = UiPathToolNode(failing_tool, handle_tool_errors=False)
311+
312+
with pytest.raises(ValueError) as exc_info:
313+
node._func(state) # type: ignore[arg-type]
314+
315+
assert "Tool execution failed: test input" in str(exc_info.value)
316+
317+
async def test_async_tool_error_propagates_when_handle_errors_false(self):
318+
"""Test that async tool errors propagate when handle_tool_errors=False."""
319+
failing_tool = MockFailingTool()
320+
tool_call = {
321+
"name": "mock_failing_tool",
322+
"args": {"input_text": "test input"},
323+
"id": "test_call_id",
324+
}
325+
ai_message = AIMessage(content="Using tool", tool_calls=[tool_call])
326+
state = MockState(messages=[ai_message])
327+
328+
node = UiPathToolNode(failing_tool, handle_tool_errors=False)
329+
330+
with pytest.raises(ValueError) as exc_info:
331+
await node._afunc(state) # type: ignore[arg-type]
332+
333+
assert "Async tool execution failed: test input" in str(exc_info.value)
334+
335+
def test_tool_error_captured_when_handle_errors_true(self):
336+
"""Test that tool errors are captured as error ToolMessages when handle_tool_errors=True."""
337+
failing_tool = MockFailingTool()
338+
tool_call = {
339+
"name": "mock_failing_tool",
340+
"args": {"input_text": "test input"},
341+
"id": "test_call_id",
342+
}
343+
ai_message = AIMessage(content="Using tool", tool_calls=[tool_call])
344+
state = MockState(messages=[ai_message])
345+
346+
node = UiPathToolNode(failing_tool, handle_tool_errors=True)
347+
348+
result = node._func(state) # type: ignore[arg-type]
349+
350+
assert result is not None
351+
assert isinstance(result, dict)
352+
assert "messages" in result
353+
assert len(result["messages"]) == 1
354+
355+
tool_message = result["messages"][0]
356+
assert isinstance(tool_message, ToolMessage)
357+
assert tool_message.name == "mock_failing_tool"
358+
assert tool_message.tool_call_id == "test_call_id"
359+
assert tool_message.status == "error"
360+
assert "Tool execution failed: test input" in tool_message.content
361+
362+
async def test_async_tool_error_captured_when_handle_errors_true(self):
363+
"""Test that async tool errors are captured as error ToolMessages when handle_tool_errors=True."""
364+
failing_tool = MockFailingTool()
365+
tool_call = {
366+
"name": "mock_failing_tool",
367+
"args": {"input_text": "test input"},
368+
"id": "test_call_id",
369+
}
370+
ai_message = AIMessage(content="Using tool", tool_calls=[tool_call])
371+
state = MockState(messages=[ai_message])
372+
373+
node = UiPathToolNode(failing_tool, handle_tool_errors=True)
374+
375+
result = await node._afunc(state) # type: ignore[arg-type]
376+
377+
assert result is not None
378+
assert isinstance(result, dict)
379+
assert "messages" in result
380+
assert len(result["messages"]) == 1
381+
382+
tool_message = result["messages"][0]
383+
assert isinstance(tool_message, ToolMessage)
384+
assert tool_message.name == "mock_failing_tool"
385+
assert tool_message.tool_call_id == "test_call_id"
386+
assert tool_message.status == "error"
387+
assert "Async tool execution failed: test input" in tool_message.content
388+
286389

287390
class TestToolWrapperMixin:
288391
"""Test cases for ToolWrapperMixin class."""
@@ -354,3 +457,28 @@ def test_create_tool_node_empty_tools(self):
354457
result = create_tool_node([])
355458

356459
assert result == {}
460+
461+
def test_create_tool_node_with_handle_errors_false(self):
462+
"""Test creating tool nodes with handle_tool_errors=False."""
463+
tools = [MockTool(name="mock_tool_1")]
464+
465+
result = create_tool_node(tools, handle_tool_errors=False)
466+
467+
assert len(result) == 1
468+
assert "mock_tool_1" in result
469+
node = result["mock_tool_1"]
470+
assert isinstance(node, UiPathToolNode)
471+
assert node.handle_tool_errors is False
472+
473+
def test_create_tool_node_with_handle_errors_true(self):
474+
"""Test creating tool nodes with handle_tool_errors=True."""
475+
tools = [MockTool(name="mock_tool_1"), MockTool(name="mock_tool_2")]
476+
477+
result = create_tool_node(tools, handle_tool_errors=True)
478+
479+
assert len(result) == 2
480+
for tool_name in ["mock_tool_1", "mock_tool_2"]:
481+
assert tool_name in result
482+
node = result[tool_name]
483+
assert isinstance(node, UiPathToolNode)
484+
assert node.handle_tool_errors is True
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ def test_map_messages_converts_dict_messages(self):
171171
"content_part_id": "part-1",
172172
"mime_type": "text/plain",
173173
"data": {"inline": "hello from dict"},
174+
"citations": [],
174175
"createdAt": "2025-01-15T10:30:00Z",
175176
"updatedAt": "2025-01-15T10:30:00Z",
176177
}
@@ -1204,8 +1205,35 @@ async def test_map_event_handles_tool_message(self):
12041205
assert event.tool_call is not None
12051206
assert event.tool_call.tool_call_id == "tool-1"
12061207
assert event.tool_call.end is not None
1208+
assert not event.tool_call.end.is_error
12071209
assert event.tool_call.end.output == {"result": "success"}
12081210

1211+
@pytest.mark.asyncio
1212+
async def test_map_event_handles_tool_message_with_error(self):
1213+
"""Should convert ToolMessage with error status to appropriate tool call end event."""
1214+
storage = create_mock_storage()
1215+
# Pre-populate the tool call mapping in storage
1216+
storage.get_value.return_value = {"tool-1": "msg-123"}
1217+
mapper = UiPathChatMessagesMapper("test-runtime", storage)
1218+
1219+
tool_msg = ToolMessage(
1220+
content='{"exception": "Tool execution failed"}',
1221+
tool_call_id="tool-1",
1222+
status="error",
1223+
)
1224+
1225+
result = await mapper.map_event(tool_msg)
1226+
1227+
assert result is not None
1228+
assert len(result) == 2 # tool call end event + message end event
1229+
event = result[0]
1230+
assert event.message_id == "msg-123"
1231+
assert event.tool_call is not None
1232+
assert event.tool_call.tool_call_id == "tool-1"
1233+
assert event.tool_call.end is not None
1234+
assert event.tool_call.end.is_error
1235+
assert event.tool_call.end.output == {"exception": "Tool execution failed"}
1236+
12091237
@pytest.mark.asyncio
12101238
async def test_map_event_cleans_up_tool_mapping_after_use(self):
12111239
"""Should remove tool_call_id from storage mapping after processing ToolMessage."""

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)