Skip to content

Commit dfafcd4

Browse files
fix: invoke the tool using modified ToolCall object (#542)
1 parent fe02955 commit dfafcd4

6 files changed

Lines changed: 80 additions & 50 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.5.36"
3+
version = "0.5.37"
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/tools/integration_tool.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,8 @@ async def integration_tool_wrapper(
179179
call: ToolCall,
180180
state: AgentGraphState,
181181
) -> ToolWrapperReturnType:
182-
modified_args = handle_static_args(resource, state, call["args"])
183-
return await tool.ainvoke(modified_args)
182+
call["args"] = handle_static_args(resource, state, call["args"])
183+
return await tool.ainvoke(call)
184184

185185
tool = StructuredToolWithWrapper(
186186
name=tool_name,

src/uipath_langchain/agent/tools/tool_node.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def _func(self, state: AgentGraphState) -> OutputType:
7575
inputs = self._prepare_wrapper_inputs(self.wrapper, self.tool, call, state)
7676
result = self.wrapper(*inputs)
7777
else:
78-
result = self.tool.invoke(call["args"])
78+
result = self.tool.invoke(call)
7979
return self._process_result(call, result)
8080

8181
async def _afunc(self, state: AgentGraphState) -> OutputType:
@@ -86,7 +86,7 @@ async def _afunc(self, state: AgentGraphState) -> OutputType:
8686
inputs = self._prepare_wrapper_inputs(self.awrapper, self.tool, call, state)
8787
result = await self.awrapper(*inputs)
8888
else:
89-
result = await self.tool.ainvoke(call["args"])
89+
result = await self.tool.ainvoke(call)
9090
return self._process_result(call, result)
9191

9292
def _extract_tool_call(self, state: AgentGraphState) -> ToolCall | None:
@@ -110,11 +110,13 @@ def _extract_tool_call(self, state: AgentGraphState) -> ToolCall | None:
110110
return latest_ai_message.tool_calls[current_tool_call_index]
111111

112112
def _process_result(
113-
self, call: ToolCall, result: dict[str, Any] | Command[Any] | None
113+
self, call: ToolCall, result: dict[str, Any] | Command[Any] | ToolMessage | None
114114
) -> OutputType:
115115
"""Process the tool result into a message format or return a Command."""
116116
if isinstance(result, Command):
117117
return result
118+
elif isinstance(result, ToolMessage):
119+
return {"messages": [result]}
118120
else:
119121
message = ToolMessage(
120122
content=str(result), name=call["name"], tool_call_id=call["id"]

src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import json
12
from typing import Any
23

3-
from langchain_core.messages.tool import ToolCall, ToolMessage
4+
from langchain_core.messages.tool import ToolCall
45
from langchain_core.tools import BaseTool
56
from langgraph.types import Command
67
from pydantic import BaseModel
@@ -14,6 +15,18 @@
1415
from uipath_langchain.agent.tools.tool_node import AsyncToolWrapperWithState
1516

1617

18+
def _parse(content: str) -> Any:
19+
if not content:
20+
return content
21+
22+
try:
23+
return json.loads(content)
24+
except (json.JSONDecodeError, TypeError):
25+
pass
26+
27+
return content
28+
29+
1730
def get_job_attachment_wrapper(
1831
output_type: Any | None = None,
1932
) -> AsyncToolWrapperWithState:
@@ -71,24 +84,20 @@ async def job_attachment_wrapper(
7184

7285
if errors:
7386
return {"error": "\n".join(errors)}
74-
75-
tool_result = await tool.ainvoke(modified_input_args)
87+
call["args"] = modified_input_args
88+
tool_result = await tool.ainvoke(call)
7689
job_attachments_dict = {}
7790
if output_type is not None:
78-
job_attachments = get_job_attachments(output_type, tool_result)
91+
job_attachments = get_job_attachments(
92+
output_type, _parse(tool_result.content)
93+
)
7994
job_attachments_dict = {
8095
str(att.id): att for att in job_attachments if att.id is not None
8196
}
8297

8398
return Command(
8499
update={
85-
"messages": [
86-
ToolMessage(
87-
content=str(tool_result),
88-
name=call["name"],
89-
tool_call_id=call["id"],
90-
)
91-
],
100+
"messages": [tool_result],
92101
"inner_state": {"job_attachments": job_attachments_dict},
93102
}
94103
)

tests/agent/wrappers/test_job_attachment_wrapper.py

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Tests for job_attachment_wrapper module."""
22

3+
import json
34
import uuid
45
from typing import Any, cast
56
from unittest.mock import AsyncMock, MagicMock, patch
67

78
import pytest
9+
from langchain_core.messages import ToolMessage
810
from langchain_core.messages.tool import ToolCall
911
from langchain_core.tools import BaseTool
1012
from langgraph.types import Command
@@ -85,7 +87,14 @@ def create_mock_attachment(self, attachment_id: uuid.UUID) -> MagicMock:
8587
def mock_tool(self):
8688
"""Create a mock tool."""
8789
tool = MagicMock(spec=BaseTool)
88-
tool.ainvoke = AsyncMock(return_value={"result": "success"})
90+
91+
async def ainvoke_side_effect(call):
92+
tool_message = ToolMessage(
93+
content="{'result': 'success'}", tool_call_id=call.get("id", "call_123")
94+
)
95+
return tool_message
96+
97+
tool.ainvoke = AsyncMock(side_effect=ainvoke_side_effect)
8998
return tool
9099

91100
@pytest.fixture
@@ -129,7 +138,7 @@ async def test_tool_without_args_schema(
129138

130139
assert isinstance(result, Command)
131140
self.assert_command_success(result)
132-
mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call["args"])
141+
mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call)
133142

134143
@pytest.mark.asyncio
135144
async def test_tool_with_dict_args_schema(
@@ -143,7 +152,7 @@ async def test_tool_with_dict_args_schema(
143152

144153
assert isinstance(result, Command)
145154
self.assert_command_success(result)
146-
mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call["args"])
155+
mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call)
147156

148157
@pytest.mark.asyncio
149158
async def test_tool_with_non_basemodel_schema(
@@ -157,7 +166,7 @@ async def test_tool_with_non_basemodel_schema(
157166

158167
assert isinstance(result, Command)
159168
self.assert_command_success(result)
160-
mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call["args"])
169+
mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call)
161170

162171
@pytest.mark.asyncio
163172
@patch(
@@ -180,7 +189,7 @@ async def test_tool_with_no_attachment_paths(
180189
assert isinstance(result, Command)
181190
self.assert_command_success(result)
182191
mock_get_paths.assert_called_once_with(MockAttachmentSchema)
183-
mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call["args"])
192+
mock_tool.ainvoke.assert_awaited_once_with(mock_tool_call)
184193

185194
@pytest.mark.asyncio
186195
@patch(
@@ -219,9 +228,9 @@ async def test_tool_with_valid_attachments(
219228
self.assert_command_success(result)
220229
# Verify that tool.ainvoke was called (with replaced attachment)
221230
mock_tool.ainvoke.assert_awaited_once()
222-
called_args = mock_tool.ainvoke.call_args[0][0]
223-
assert called_args["name"] == "test"
224-
assert "attachment" in called_args
231+
called_tool_call = mock_tool.ainvoke.call_args[0][0]
232+
assert called_tool_call["args"]["name"] == "test"
233+
assert "attachment" in called_tool_call["args"]
225234

226235
@pytest.mark.asyncio
227236
@patch(
@@ -557,7 +566,8 @@ async def test_tool_with_complex_nested_structure_all_valid(
557566
self.assert_command_success(result, tool_call_id="call_complex_456")
558567
# Tool should be invoked with replaced attachments
559568
mock_tool.ainvoke.assert_awaited_once()
560-
called_args = mock_tool.ainvoke.call_args[0][0]
569+
called_tool_call = mock_tool.ainvoke.call_args[0][0]
570+
called_args = called_tool_call["args"]
561571

562572
# Verify structure is preserved
563573
assert "request" in called_args
@@ -588,12 +598,14 @@ async def test_structured_tool_with_output_attachments(
588598
# Create a tool with output type
589599
mock_tool = MagicMock(spec=BaseTool)
590600
mock_tool.args_schema = None
591-
mock_tool.ainvoke = AsyncMock(
592-
return_value={
593-
"result_attachment": output_attachment.model_dump(),
594-
"additional_attachments": [],
595-
}
601+
tool_output = {
602+
"result_attachment": output_attachment.model_dump(),
603+
"additional_attachments": [],
604+
}
605+
tool_message = ToolMessage(
606+
content=json.dumps(tool_output), tool_call_id=mock_tool_call["id"]
596607
)
608+
mock_tool.ainvoke = AsyncMock(return_value=tool_message)
597609

598610
wrapper = get_job_attachment_wrapper(output_type=MockOutputSchema)
599611
result = await wrapper(mock_tool, mock_tool_call, mock_state)
@@ -606,9 +618,7 @@ async def test_structured_tool_with_output_attachments(
606618
expected_content=None,
607619
)
608620
# Verify get_job_attachments was called with correct parameters
609-
mock_get_job_attachments.assert_called_once_with(
610-
MockOutputSchema, mock_tool.ainvoke.return_value
611-
)
621+
mock_get_job_attachments.assert_called_once_with(MockOutputSchema, tool_output)
612622

613623
@pytest.mark.asyncio
614624
@patch("uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachments")
@@ -631,15 +641,17 @@ async def test_structured_tool_with_multiple_output_attachments(
631641
# Create a tool with output type
632642
mock_tool = MagicMock(spec=BaseTool)
633643
mock_tool.args_schema = None
634-
mock_tool.ainvoke = AsyncMock(
635-
return_value={
636-
"result_attachment": attachment1.model_dump(),
637-
"additional_attachments": [
638-
attachment2.model_dump(),
639-
attachment3.model_dump(),
640-
],
641-
}
644+
tool_output = {
645+
"result_attachment": attachment1.model_dump(),
646+
"additional_attachments": [
647+
attachment2.model_dump(),
648+
attachment3.model_dump(),
649+
],
650+
}
651+
tool_message = ToolMessage(
652+
content=json.dumps(tool_output), tool_call_id=mock_tool_call["id"]
642653
)
654+
mock_tool.ainvoke = AsyncMock(return_value=tool_message)
643655

644656
wrapper = get_job_attachment_wrapper(output_type=MockOutputSchema)
645657
result = await wrapper(mock_tool, mock_tool_call, mock_state)
@@ -668,9 +680,14 @@ async def test_structured_tool_with_no_output_attachments(
668680
# Create a tool with output type
669681
mock_tool = MagicMock(spec=BaseTool)
670682
mock_tool.args_schema = None
671-
mock_tool.ainvoke = AsyncMock(
672-
return_value={"result_attachment": None, "additional_attachments": []}
683+
tool_output: dict[str, Any] = {
684+
"result_attachment": None,
685+
"additional_attachments": [],
686+
}
687+
tool_message = ToolMessage(
688+
content=json.dumps(tool_output), tool_call_id=mock_tool_call["id"]
673689
)
690+
mock_tool.ainvoke = AsyncMock(return_value=tool_message)
674691

675692
wrapper = get_job_attachment_wrapper(output_type=MockOutputSchema)
676693
result = await wrapper(mock_tool, mock_tool_call, mock_state)
@@ -703,12 +720,14 @@ async def test_structured_tool_filters_attachments_with_none_id(
703720
# Create a tool with output type
704721
mock_tool = MagicMock(spec=BaseTool)
705722
mock_tool.args_schema = None
706-
mock_tool.ainvoke = AsyncMock(
707-
return_value={
708-
"result_attachment": attachment_with_id.model_dump(),
709-
"additional_attachments": [attachment_without_id.model_dump()],
710-
}
723+
tool_output = {
724+
"result_attachment": attachment_with_id.model_dump(),
725+
"additional_attachments": [attachment_without_id.model_dump()],
726+
}
727+
tool_message = ToolMessage(
728+
content=json.dumps(tool_output), tool_call_id=mock_tool_call["id"]
711729
)
730+
mock_tool.ainvoke = AsyncMock(return_value=tool_message)
712731

713732
wrapper = get_job_attachment_wrapper(output_type=MockOutputSchema)
714733
result = await wrapper(mock_tool, mock_tool_call, mock_state)

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)