Skip to content

Commit f4935c0

Browse files
authored
fix!: Improve response handling from Toolbox server (#69)
* fix: Make all fields required in Tool schema. Earlier we made all fields as optional since we wanted to keep some fields optional for the LLM. Since Toolbox did not support optional fields, there was no way to know which fields were optional, so as a worst-case, we did a temporary workaround of keeping all fields as optional in the schema generated by Toolbox SDK. Now, there has been some evidence that the LLMs do not work very well with optional parameters, and so we have decided not to support optional fields for now, neither in Toolbox service nor in the SDK. This PR removes that temporary fix of making all the fields optional. This PR also removes an augmentation to the request body where `None` values were converted to empty strings (`''`). This is because now that LLM knows no fields are optional, we can be sure that we would not be getting any `None` values as inputs to the tools. So the function `_convert_none_to_empty_string` is not required anymore. * chore: Update unit tests. * fix!: Improve response handling from Toolbox server. * We remove `response.raise_for_status()` as it masks error reasons thrown by Toolbox server. * We return the value of `result` key from the response body, so that it can directly be fed to LLMs. * This would also prevent situations where the response is `{ "result": '{ "some": "value" }' }` and when we feed that to LLM by stringifying, it becomes something like `"{ "result": '{ \"some\": \"value\" }' }"` which is more cryptic for the LLM because of the extra `\` characters due to double stringification. * We also check for `error` in the response and throw a `ToolException` with the response if applicable. * chore: Update test cases. * fix: Make all fields required in Tool schema. Earlier we made all fields as optional since we wanted to keep some fields optional for the LLM. Since Toolbox did not support optional fields, there was no way to know which fields were optional, so as a worst-case, we did a temporary workaround of keeping all fields as optional in the schema generated by Toolbox SDK. Now, there has been some evidence that the LLMs do not work very well with optional parameters, and so we have decided not to support optional fields for now, neither in Toolbox service nor in the SDK. This PR removes that temporary fix of making all the fields optional. This PR also removes an augmentation to the request body where `None` values were converted to empty strings (`''`). This is because now that LLM knows no fields are optional, we can be sure that we would not be getting any `None` values as inputs to the tools. So the function `_convert_none_to_empty_string` is not required anymore. * chore: Update unit tests. * fix: Make all fields required in Tool schema. Earlier we made all fields as optional since we wanted to keep some fields optional for the LLM. Since Toolbox did not support optional fields, there was no way to know which fields were optional, so as a worst-case, we did a temporary workaround of keeping all fields as optional in the schema generated by Toolbox SDK. Now, there has been some evidence that the LLMs do not work very well with optional parameters, and so we have decided not to support optional fields for now, neither in Toolbox service nor in the SDK. This PR removes that temporary fix of making all the fields optional. This PR also removes an augmentation to the request body where `None` values were converted to empty strings (`''`). This is because now that LLM knows no fields are optional, we can be sure that we would not be getting any `None` values as inputs to the tools. So the function `_convert_none_to_empty_string` is not required anymore. * chore: Update unit tests. * fix: Make all fields required in Tool schema. Earlier we made all fields as optional since we wanted to keep some fields optional for the LLM. Since Toolbox did not support optional fields, there was no way to know which fields were optional, so as a worst-case, we did a temporary workaround of keeping all fields as optional in the schema generated by Toolbox SDK. Now, there has been some evidence that the LLMs do not work very well with optional parameters, and so we have decided not to support optional fields for now, neither in Toolbox service nor in the SDK. This PR removes that temporary fix of making all the fields optional. This PR also removes an augmentation to the request body where `None` values were converted to empty strings (`''`). This is because now that LLM knows no fields are optional, we can be sure that we would not be getting any `None` values as inputs to the tools. So the function `_convert_none_to_empty_string` is not required anymore. * chore: Update unit tests.
1 parent 2d7d095 commit f4935c0

File tree

3 files changed

+59
-42
lines changed

3 files changed

+59
-42
lines changed

src/toolbox_langchain/utils.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from aiohttp import ClientSession
2020
from deprecated import deprecated
21+
from langchain_core.tools import ToolException
2122
from pydantic import BaseModel, Field, create_model
2223

2324

@@ -187,6 +188,9 @@ async def _invoke_tool(
187188
Returns:
188189
A dictionary containing the parsed JSON response from the tool
189190
invocation.
191+
192+
Raises:
193+
ToolException: If the Toolbox service returns an error.
190194
"""
191195
url = f"{url}/api/tool/{tool_name}/invoke"
192196
auth_tokens = _get_auth_tokens(id_token_getters)
@@ -204,9 +208,10 @@ async def _invoke_tool(
204208
json=data,
205209
headers=auth_tokens,
206210
) as response:
207-
# TODO: Remove as it masks error messages.
208-
response.raise_for_status()
209-
return await response.json()
211+
ret = await response.json()
212+
if "error" in ret:
213+
raise ToolException(ret)
214+
return ret.get("result", ret)
210215

211216

212217
def _find_auth_params(

tests/test_async_tools.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ async def test_toolbox_tool_validate_auth_strict(self, auth_toolbox_tool):
196196

197197
async def test_toolbox_tool_call(self, toolbox_tool):
198198
result = await toolbox_tool.ainvoke({"param1": "test-value", "param2": 123})
199-
assert result == {"result": "test-result"}
199+
assert result == "test-result"
200200
toolbox_tool._AsyncToolboxTool__session.post.assert_called_once_with(
201201
"http://test_url/api/tool/test_tool/invoke",
202202
json={"param1": "test-value", "param2": 123},
@@ -215,7 +215,7 @@ async def test_toolbox_tool_call_with_bound_params(
215215
):
216216
tool = toolbox_tool.bind_params(bound_param)
217217
result = await tool.ainvoke({"param2": 123})
218-
assert result == {"result": "test-result"}
218+
assert result == "test-result"
219219
toolbox_tool._AsyncToolboxTool__session.post.assert_called_once_with(
220220
"http://test_url/api/tool/test_tool/invoke",
221221
json={"param1": expected_value, "param2": 123},
@@ -227,7 +227,7 @@ async def test_toolbox_tool_call_with_auth_tokens(self, auth_toolbox_tool):
227227
{"test-auth-source": lambda: "test-token"}
228228
)
229229
result = await tool.ainvoke({"param2": 123})
230-
assert result == {"result": "test-result"}
230+
assert result == "test-result"
231231
auth_toolbox_tool._AsyncToolboxTool__session.post.assert_called_once_with(
232232
"https://test-url/api/tool/test_tool/invoke",
233233
json={"param2": 123},
@@ -244,7 +244,7 @@ async def test_toolbox_tool_call_with_auth_tokens_insecure(self, auth_toolbox_to
244244
{"test-auth-source": lambda: "test-token"}
245245
)
246246
result = await tool.ainvoke({"param2": 123})
247-
assert result == {"result": "test-result"}
247+
assert result == "test-result"
248248
auth_toolbox_tool._AsyncToolboxTool__session.post.assert_called_once_with(
249249
"http://test-url/api/tool/test_tool/invoke",
250250
json={"param2": 123},

tests/test_e2e.py

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
import pytest
3838
import pytest_asyncio
39-
from aiohttp import ClientResponseError
39+
from langchain_core.tools import ToolException
4040
from pydantic import ValidationError
4141

4242
from toolbox_langchain.client import ToolboxClient
@@ -90,19 +90,17 @@ async def test_aload_toolset_all(self, toolbox):
9090

9191
async def test_run_tool_async(self, get_n_rows_tool):
9292
response = await get_n_rows_tool.ainvoke({"num_rows": "2"})
93-
result = response["result"]
9493

95-
assert "row1" in result
96-
assert "row2" in result
97-
assert "row3" not in result
94+
assert "row1" in response
95+
assert "row2" in response
96+
assert "row3" not in response
9897

9998
async def test_run_tool_sync(self, get_n_rows_tool):
10099
response = get_n_rows_tool.invoke({"num_rows": "2"})
101-
result = response["result"]
102100

103-
assert "row1" in result
104-
assert "row2" in result
105-
assert "row3" not in result
101+
assert "row1" in response
102+
assert "row2" in response
103+
assert "row3" not in response
106104

107105
async def test_run_tool_missing_params(self, get_n_rows_tool):
108106
with pytest.raises(ValidationError, match="Field required"):
@@ -120,14 +118,17 @@ async def test_run_tool_unauth_with_auth(self, toolbox, auth_token2):
120118
"get-row-by-id", auth_tokens={"my-test-auth": lambda: auth_token2}
121119
)
122120
response = await tool.ainvoke({"id": "2"})
123-
assert "row2" in response["result"]
121+
assert "row2" in response
124122

125123
async def test_run_tool_no_auth(self, toolbox):
126124
"""Tests running a tool requiring auth without providing auth."""
127125
tool = await toolbox.aload_tool(
128126
"get-row-by-id-auth",
129127
)
130-
with pytest.raises(ClientResponseError, match="401, message='Unauthorized'"):
128+
with pytest.raises(
129+
ToolException,
130+
match="{'status': 'Unauthorized', 'error': 'tool invocation not authorized. Please make sure your specify correct auth headers'}",
131+
):
131132
await tool.ainvoke({"id": "2"})
132133

133134
async def test_run_tool_wrong_auth(self, toolbox, auth_token2):
@@ -136,7 +137,10 @@ async def test_run_tool_wrong_auth(self, toolbox, auth_token2):
136137
"get-row-by-id-auth",
137138
)
138139
auth_tool = tool.add_auth_token("my-test-auth", lambda: auth_token2)
139-
with pytest.raises(ClientResponseError, match="401, message='Unauthorized'"):
140+
with pytest.raises(
141+
ToolException,
142+
match="{'status': 'Unauthorized', 'error': 'tool invocation not authorized. Please make sure your specify correct auth headers'}",
143+
):
140144
await auth_tool.ainvoke({"id": "2"})
141145

142146
async def test_run_tool_auth(self, toolbox, auth_token1):
@@ -146,7 +150,7 @@ async def test_run_tool_auth(self, toolbox, auth_token1):
146150
)
147151
auth_tool = tool.add_auth_token("my-test-auth", lambda: auth_token1)
148152
response = await auth_tool.ainvoke({"id": "2"})
149-
assert "row2" in response["result"]
153+
assert "row2" in response
150154

151155
async def test_run_tool_param_auth_no_auth(self, toolbox):
152156
"""Tests running a tool with a param requiring auth, without auth."""
@@ -163,17 +167,19 @@ async def test_run_tool_param_auth(self, toolbox, auth_token1):
163167
"get-row-by-email-auth", auth_tokens={"my-test-auth": lambda: auth_token1}
164168
)
165169
response = await tool.ainvoke({})
166-
result = response["result"]
167-
assert "row4" in result
168-
assert "row5" in result
169-
assert "row6" in result
170+
assert "row4" in response
171+
assert "row5" in response
172+
assert "row6" in response
170173

171174
async def test_run_tool_param_auth_no_field(self, toolbox, auth_token1):
172175
"""Tests running a tool with a param requiring auth, with insufficient auth."""
173176
tool = await toolbox.aload_tool(
174177
"get-row-by-content-auth", auth_tokens={"my-test-auth": lambda: auth_token1}
175178
)
176-
with pytest.raises(ClientResponseError, match="400, message='Bad Request'"):
179+
with pytest.raises(
180+
ToolException,
181+
match="{'status': 'Bad Request', 'error': 'provided parameters were invalid: error parsing authenticated parameter \"data\": no field named row_data in claims'}",
182+
):
177183
await tool.ainvoke({})
178184

179185

@@ -225,19 +231,17 @@ def test_aload_toolset_all(self, toolbox):
225231
@pytest.mark.asyncio
226232
async def test_run_tool_async(self, get_n_rows_tool):
227233
response = await get_n_rows_tool.ainvoke({"num_rows": "2"})
228-
result = response["result"]
229234

230-
assert "row1" in result
231-
assert "row2" in result
232-
assert "row3" not in result
235+
assert "row1" in response
236+
assert "row2" in response
237+
assert "row3" not in response
233238

234239
def test_run_tool_sync(self, get_n_rows_tool):
235240
response = get_n_rows_tool.invoke({"num_rows": "2"})
236-
result = response["result"]
237241

238-
assert "row1" in result
239-
assert "row2" in result
240-
assert "row3" not in result
242+
assert "row1" in response
243+
assert "row2" in response
244+
assert "row3" not in response
241245

242246
def test_run_tool_missing_params(self, get_n_rows_tool):
243247
with pytest.raises(ValidationError, match="Field required"):
@@ -254,14 +258,17 @@ def test_run_tool_unauth_with_auth(self, toolbox, auth_token2):
254258
"get-row-by-id", auth_tokens={"my-test-auth": lambda: auth_token2}
255259
)
256260
response = tool.invoke({"id": "2"})
257-
assert "row2" in response["result"]
261+
assert "row2" in response
258262

259263
def test_run_tool_no_auth(self, toolbox):
260264
"""Tests running a tool requiring auth without providing auth."""
261265
tool = toolbox.load_tool(
262266
"get-row-by-id-auth",
263267
)
264-
with pytest.raises(ClientResponseError, match="401, message='Unauthorized'"):
268+
with pytest.raises(
269+
ToolException,
270+
match="{'status': 'Unauthorized', 'error': 'tool invocation not authorized. Please make sure your specify correct auth headers'}",
271+
):
265272
tool.invoke({"id": "2"})
266273

267274
def test_run_tool_wrong_auth(self, toolbox, auth_token2):
@@ -270,7 +277,10 @@ def test_run_tool_wrong_auth(self, toolbox, auth_token2):
270277
"get-row-by-id-auth",
271278
)
272279
auth_tool = tool.add_auth_token("my-test-auth", lambda: auth_token2)
273-
with pytest.raises(ClientResponseError, match="401, message='Unauthorized'"):
280+
with pytest.raises(
281+
ToolException,
282+
match="{'status': 'Unauthorized', 'error': 'tool invocation not authorized. Please make sure your specify correct auth headers'}",
283+
):
274284
auth_tool.invoke({"id": "2"})
275285

276286
def test_run_tool_auth(self, toolbox, auth_token1):
@@ -280,7 +290,7 @@ def test_run_tool_auth(self, toolbox, auth_token1):
280290
)
281291
auth_tool = tool.add_auth_token("my-test-auth", lambda: auth_token1)
282292
response = auth_tool.invoke({"id": "2"})
283-
assert "row2" in response["result"]
293+
assert "row2" in response
284294

285295
def test_run_tool_param_auth_no_auth(self, toolbox):
286296
"""Tests running a tool with a param requiring auth, without auth."""
@@ -297,15 +307,17 @@ def test_run_tool_param_auth(self, toolbox, auth_token1):
297307
"get-row-by-email-auth", auth_tokens={"my-test-auth": lambda: auth_token1}
298308
)
299309
response = tool.invoke({})
300-
result = response["result"]
301-
assert "row4" in result
302-
assert "row5" in result
303-
assert "row6" in result
310+
assert "row4" in response
311+
assert "row5" in response
312+
assert "row6" in response
304313

305314
def test_run_tool_param_auth_no_field(self, toolbox, auth_token1):
306315
"""Tests running a tool with a param requiring auth, with insufficient auth."""
307316
tool = toolbox.load_tool(
308317
"get-row-by-content-auth", auth_tokens={"my-test-auth": lambda: auth_token1}
309318
)
310-
with pytest.raises(ClientResponseError, match="400, message='Bad Request'"):
319+
with pytest.raises(
320+
ToolException,
321+
match="{'status': 'Bad Request', 'error': 'provided parameters were invalid: error parsing authenticated parameter \"data\": no field named row_data in claims'}",
322+
):
311323
tool.invoke({})

0 commit comments

Comments
 (0)