Skip to content

Commit 3350e36

Browse files
ampagentIsaac Miller
andcommitted
fix(types): parse null and {} as empty dspy.ToolCalls
Models in JSON mode (e.g. DeepSeek-V4-Flash with JSONAdapter) often write "tool_calls": null or {} to mean "no tool calls". ToolCalls accepted [] but rejected both, so ReActV2 ended its loop with parse_error and the forced submit raised AdapterParseError. Treat None and {} like [] in ToolCalls.validate_input. Optional fields (ToolCalls | None) still validate None as None. Amp-Thread-ID: https://ampcode.com/threads/T-01a126ab-8118-77c7-a811-67f49e5f20cd Co-authored-by: Isaac Miller <isaac@cmpnd.ai>
1 parent f1260ff commit 3350e36

3 files changed

Lines changed: 22 additions & 0 deletions

File tree

‎dspy/adapters/types/tool.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,10 @@ def validate_input(cls, data: Any):
412412
if isinstance(data, cls):
413413
return data
414414

415+
# Models in JSON mode often write `null` or `{}` to mean "no tool calls"; treat them like `[]`.
416+
if data is None or data == {}:
417+
return {"tool_calls": []}
418+
415419
# Handle case where data is a list of dicts with either DSPy or provider-shaped tool call keys.
416420
if isinstance(data, list) and all(isinstance(item, dict) and _is_tool_call_dict(item) for item in data):
417421
return {"tool_calls": [cls.ToolCall(**_normalize_tool_call_dict(item)) for item in data]}

‎tests/adapters/test_tool.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,10 @@ def test_toolcalls_vague_match():
656656
assert len(tc.tool_calls) == 1
657657
assert tc.tool_calls[0].args == {"query": "hello"}
658658

659+
# `null` and `{}` mean "no tool calls", like `[]`.
660+
for empty in (None, {}, []):
661+
assert ToolCalls.model_validate(empty).tool_calls == []
662+
659663
# Invalid input should raise ValueError
660664
with pytest.raises(ValueError):
661665
ToolCalls.model_validate({"foo": "bar"})

‎tests/predict/test_react_v2.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import json
23

34
import pytest
45

@@ -199,6 +200,19 @@ def test_react_v2_forced_submit_on_empty_tool_calls():
199200
assert lm.history[1]["kwargs"].get("reasoning_effort") is None
200201

201202

203+
@pytest.mark.parametrize("empty_tool_calls", [None, {}])
204+
def test_react_v2_json_null_or_empty_tool_calls_mean_no_tool_calls(empty_tool_calls):
205+
adapter = dspy.JSONAdapter()
206+
lm = dspy.utils.DummyLM([{"next_thought": "Nothing to call.", "tool_calls": empty_tool_calls}] * 2, adapter=adapter)
207+
208+
with dspy.context(lm=lm, adapter=adapter):
209+
with pytest.raises(ValueError, match="after empty_tool_calls: no submit call"):
210+
dspy.ReActV2("question -> answer", tools=[])(question="cats")
211+
212+
assert f'"tool_calls": {json.dumps(empty_tool_calls)}' in lm.history[0]["outputs"][0]
213+
assert len(lm.history) == 2
214+
215+
202216
def test_react_v2_forced_submit_with_native_flag_but_unsupported_lm():
203217
adapter = dspy.JSONAdapter(use_native_function_calling=True)
204218
lm = dspy.utils.DummyLM(

0 commit comments

Comments
 (0)