Skip to content

Commit cdc0ad6

Browse files
authored
fix: #2061 handle invalid tool arguments JSON without crashing (#2337)
1 parent 88cda62 commit cdc0ad6

File tree

5 files changed

+145
-22
lines changed

5 files changed

+145
-22
lines changed

src/agents/_run_impl.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -970,7 +970,7 @@ async def run_single_tool(
970970

971971
if rejected_message is not None:
972972
# Input guardrail rejected the tool call
973-
final_result = rejected_message
973+
result = rejected_message
974974
else:
975975
# 2) Actually run the tool
976976
real_result = await cls._execute_tool_with_hooks(
@@ -1001,7 +1001,7 @@ async def run_single_tool(
10011001
else _coro.noop_coroutine()
10021002
),
10031003
)
1004-
result = final_result
1004+
result = final_result
10051005
except Exception as e:
10061006
_error_tracing.attach_error_to_current_span(
10071007
SpanError(

src/agents/tool.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -668,8 +668,32 @@ def type(self) -> str:
668668
"""A tool that can be used in an agent."""
669669

670670

671+
def _extract_json_decode_error(error: BaseException) -> json.JSONDecodeError | None:
672+
current: BaseException | None = error
673+
while current is not None:
674+
if isinstance(current, json.JSONDecodeError):
675+
return current
676+
current = current.__cause__ or current.__context__
677+
return None
678+
679+
680+
def _extract_tool_argument_json_error(error: Exception) -> json.JSONDecodeError | None:
681+
if not isinstance(error, ModelBehaviorError):
682+
return None
683+
if not str(error).startswith("Invalid JSON input for tool"):
684+
return None
685+
return _extract_json_decode_error(error)
686+
687+
671688
def default_tool_error_function(ctx: RunContextWrapper[Any], error: Exception) -> str:
672689
"""The default tool error function, which just returns a generic error message."""
690+
json_decode_error = _extract_tool_argument_json_error(error)
691+
if json_decode_error is not None:
692+
return (
693+
"An error occurred while parsing tool arguments. "
694+
"Please try again with valid JSON. "
695+
f"Error: {json_decode_error}"
696+
)
673697
return f"An error occurred while running the tool. Please try again. Error: {str(error)}"
674698

675699

@@ -828,12 +852,20 @@ async def _on_invoke_tool(ctx: ToolContext[Any], input: str) -> Any:
828852
if inspect.isawaitable(result):
829853
return await result
830854

855+
json_decode_error = _extract_tool_argument_json_error(e)
856+
if json_decode_error is not None:
857+
span_error_message = "Error running tool"
858+
span_error_detail = str(json_decode_error)
859+
else:
860+
span_error_message = "Error running tool (non-fatal)"
861+
span_error_detail = str(e)
862+
831863
_error_tracing.attach_error_to_current_span(
832864
SpanError(
833-
message="Error running tool (non-fatal)",
865+
message=span_error_message,
834866
data={
835867
"tool_name": schema.name,
836-
"error": str(e),
868+
"error": span_error_detail,
837869
},
838870
)
839871
)

tests/test_run_step_execution.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
from agents import (
1010
Agent,
1111
MessageOutputItem,
12+
ModelBehaviorError,
1213
ModelResponse,
1314
RunConfig,
1415
RunContextWrapper,
1516
RunHooks,
1617
RunItem,
1718
ToolCallItem,
1819
ToolCallOutputItem,
20+
ToolGuardrailFunctionOutput,
21+
ToolInputGuardrail,
1922
TResponseInputItem,
2023
Usage,
2124
)
@@ -288,6 +291,64 @@ async def test_multiple_final_output_leads_to_final_output_next_step():
288291
assert result.next_step.output == Foo(bar="456")
289292

290293

294+
@pytest.mark.asyncio
295+
async def test_input_guardrail_runs_on_invalid_json():
296+
guardrail_calls: list[str] = []
297+
298+
def guardrail(data) -> ToolGuardrailFunctionOutput:
299+
guardrail_calls.append(data.context.tool_arguments)
300+
return ToolGuardrailFunctionOutput.allow(output_info="checked")
301+
302+
guardrail_obj: ToolInputGuardrail[Any] = ToolInputGuardrail(guardrail_function=guardrail)
303+
304+
def _echo(value: str) -> str:
305+
return value
306+
307+
tool = function_tool(
308+
_echo,
309+
name_override="guarded",
310+
tool_input_guardrails=[guardrail_obj],
311+
)
312+
agent = Agent(name="test", tools=[tool])
313+
response = ModelResponse(
314+
output=[get_function_tool_call("guarded", "bad_json")],
315+
usage=Usage(),
316+
response_id=None,
317+
)
318+
319+
result = await get_execute_result(agent, response)
320+
321+
assert guardrail_calls == ["bad_json"]
322+
assert result.tool_input_guardrail_results
323+
assert result.tool_input_guardrail_results[0].output.output_info == "checked"
324+
325+
output_item = next(
326+
item for item in result.generated_items if isinstance(item, ToolCallOutputItem)
327+
)
328+
assert "An error occurred while parsing tool arguments" in str(output_item.output)
329+
330+
331+
@pytest.mark.asyncio
332+
async def test_invalid_json_raises_with_failure_error_function_none():
333+
def _echo(value: str) -> str:
334+
return value
335+
336+
tool = function_tool(
337+
_echo,
338+
name_override="guarded",
339+
failure_error_function=None,
340+
)
341+
agent = Agent(name="test", tools=[tool])
342+
response = ModelResponse(
343+
output=[get_function_tool_call("guarded", "bad_json")],
344+
usage=Usage(),
345+
response_id=None,
346+
)
347+
348+
with pytest.raises(ModelBehaviorError, match="Invalid JSON input for tool"):
349+
await get_execute_result(agent, response)
350+
351+
291352
# === Helpers ===
292353

293354

tests/test_tracing_errors.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
InputGuardrail,
1414
InputGuardrailTripwireTriggered,
1515
MaxTurnsExceeded,
16-
ModelBehaviorError,
1716
RunContextWrapper,
1817
Runner,
1918
TResponseInputItem,
@@ -140,15 +139,22 @@ async def test_tool_call_error():
140139
agent = Agent(
141140
name="test_agent",
142141
model=model,
143-
tools=[get_function_tool("foo", "tool_result", hide_errors=True)],
142+
tools=[get_function_tool("foo", "tool_result")],
144143
)
145144

146-
model.set_next_output(
147-
[get_text_message("a_message"), get_function_tool_call("foo", "bad_json")],
145+
model.add_multiple_turn_outputs(
146+
[
147+
[get_text_message("a_message"), get_function_tool_call("foo", "bad_json")],
148+
[get_text_message("done")],
149+
]
148150
)
149151

150-
with pytest.raises(ModelBehaviorError):
151-
await Runner.run(agent, input="first_test")
152+
result = await Runner.run(agent, input="first_test")
153+
154+
tool_outputs = [item for item in result.new_items if item.type == "tool_call_output_item"]
155+
assert tool_outputs, "Expected a tool output item for invalid JSON"
156+
assert "An error occurred while parsing tool arguments" in str(tool_outputs[0].output)
157+
assert "valid JSON" in str(tool_outputs[0].output)
152158

153159
assert fetch_normalized_spans() == snapshot(
154160
[
@@ -171,11 +177,20 @@ async def test_tool_call_error():
171177
"message": "Error running tool",
172178
"data": {
173179
"tool_name": "foo",
174-
"error": "Invalid JSON input for tool foo: bad_json",
180+
"error": "Expecting value: line 1 column 1 (char 0)",
175181
},
176182
},
177-
"data": {"name": "foo", "input": "bad_json"},
183+
"data": {
184+
"name": "foo",
185+
"input": "bad_json",
186+
"output": (
187+
"An error occurred while parsing tool arguments. "
188+
"Please try again with valid JSON. Error: Expecting "
189+
"value: line 1 column 1 (char 0)"
190+
),
191+
},
178192
},
193+
{"type": "generation"},
179194
],
180195
}
181196
],

tests/test_tracing_errors_streamed.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
InputGuardrail,
1515
InputGuardrailTripwireTriggered,
1616
MaxTurnsExceeded,
17-
ModelBehaviorError,
1817
OutputGuardrail,
1918
OutputGuardrailTripwireTriggered,
2019
RunContextWrapper,
@@ -149,17 +148,24 @@ async def test_tool_call_error():
149148
agent = Agent(
150149
name="test_agent",
151150
model=model,
152-
tools=[get_function_tool("foo", "tool_result", hide_errors=True)],
151+
tools=[get_function_tool("foo", "tool_result")],
153152
)
154153

155-
model.set_next_output(
156-
[get_text_message("a_message"), get_function_tool_call("foo", "bad_json")],
154+
model.add_multiple_turn_outputs(
155+
[
156+
[get_text_message("a_message"), get_function_tool_call("foo", "bad_json")],
157+
[get_text_message("done")],
158+
]
157159
)
158160

159-
with pytest.raises(ModelBehaviorError):
160-
result = Runner.run_streamed(agent, input="first_test")
161-
async for _ in result.stream_events():
162-
pass
161+
result = Runner.run_streamed(agent, input="first_test")
162+
async for _ in result.stream_events():
163+
pass
164+
165+
tool_outputs = [item for item in result.new_items if item.type == "tool_call_output_item"]
166+
assert tool_outputs, "Expected a tool output item for invalid JSON"
167+
assert "An error occurred while parsing tool arguments" in str(tool_outputs[0].output)
168+
assert "valid JSON" in str(tool_outputs[0].output)
163169

164170
assert fetch_normalized_spans() == snapshot(
165171
[
@@ -182,11 +188,20 @@ async def test_tool_call_error():
182188
"message": "Error running tool",
183189
"data": {
184190
"tool_name": "foo",
185-
"error": "Invalid JSON input for tool foo: bad_json",
191+
"error": "Expecting value: line 1 column 1 (char 0)",
186192
},
187193
},
188-
"data": {"name": "foo", "input": "bad_json"},
194+
"data": {
195+
"name": "foo",
196+
"input": "bad_json",
197+
"output": (
198+
"An error occurred while parsing tool arguments. "
199+
"Please try again with valid JSON. Error: Expecting "
200+
"value: line 1 column 1 (char 0)"
201+
),
202+
},
189203
},
204+
{"type": "generation"},
190205
],
191206
}
192207
],

0 commit comments

Comments
 (0)