Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dspy/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ def parse_response(self, plan: dict[str, Any], response: LMResponse, lm: BaseLM)

if output.tool_calls and tool_call_output_field_name:
value[tool_call_output_field_name] = ToolCalls.from_dict_list(
[{"name": call.name, "args": call.args} for call in output.tool_calls]
[{"name": call.name, "args": call.args, "id": call.id} for call in output.tool_calls]
)

# Parse custom types that do not rely on the `Adapter.parse()` text parser.
Expand Down
16 changes: 16 additions & 0 deletions dspy/adapters/types/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pydantic
from pydantic import Field, model_validator

from dspy.adapters.types.tool import ToolCalls
from dspy.core.types import LMMessage


Expand All @@ -27,6 +28,21 @@ class HistoryFrame(pydantic.BaseModel):

model_config = pydantic.ConfigDict(extra="forbid")

@model_validator(mode="after")
def _normalize_tool_calls_outputs(self) -> "HistoryFrame":
normalized_outputs = {}
changed = False
for key, value in self.outputs.items():
if isinstance(value, dict) and set(value.keys()) == {"tool_calls"} and isinstance(value["tool_calls"], list):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The condition set(value.keys()) == {"tool_calls"} requires the serialized dict to contain only the "tool_calls" key. If ToolCalls serialization ever gains additional envelope fields (e.g. a version tag, metadata), or if a caller manually adds context alongside "tool_calls", this check silently falls through and the value is left as a raw dict rather than a ToolCalls instance. Consider documenting the intentional strictness or relaxing to a membership check to avoid a silent deserialization miss.

Suggested change
if isinstance(value, dict) and set(value.keys()) == {"tool_calls"} and isinstance(value["tool_calls"], list):
if isinstance(value, dict) and "tool_calls" in value and isinstance(value["tool_calls"], list) and len(value) == 1:

normalized_outputs[key] = ToolCalls.model_validate(value)
changed = True
else:
normalized_outputs[key] = value

if changed:
self.outputs = normalized_outputs
return self


HistoryEntry = HistoryFrame | dict[str, Any]

Expand Down
122 changes: 103 additions & 19 deletions dspy/adapters/types/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
import inspect
from typing import TYPE_CHECKING, Any, Callable, get_origin, get_type_hints

import json_repair
import pydantic
from jsonschema import ValidationError, validate
from pydantic import BaseModel, TypeAdapter, create_model

from dspy.adapters.types.base_type import Type, warn_legacy_type_method
from dspy.core.types import LMToolCallPart
from dspy.dsp.utils.settings import settings
from dspy.utils.callback import with_callbacks

Expand Down Expand Up @@ -265,16 +267,23 @@ class ToolCalls(Type):
class ToolCall(Type):
name: str
args: dict[str, Any]
id: str | None = None

def format(self):
warn_legacy_type_method("ToolCalls.ToolCall.format()")
return {
formatted = {
"type": "function",
"function": {
"name": self.name,
"arguments": self.args,
},
}
if self.id is not None:
formatted["id"] = self.id
return formatted

def to_lm_part(self, tool_call_id: str | None = None) -> LMToolCallPart:
return LMToolCallPart(id=tool_call_id or self.id, name=self.name, args=self.args)
Comment on lines +285 to +286

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The or operator makes tool_call_id or self.id treat any falsy value — including an empty string "" — the same as None, silently falling back to self.id. While an empty-string ID is unlikely in practice today, an explicit None guard is the conventional pattern for "use argument if explicitly provided, else fall back to default", and avoids the subtle footgun if a provider ever emits a zero-length ID token.

Suggested change
def to_lm_part(self, tool_call_id: str | None = None) -> LMToolCallPart:
return LMToolCallPart(id=tool_call_id or self.id, name=self.name, args=self.args)
def to_lm_part(self, tool_call_id: str | None = None) -> LMToolCallPart:
return LMToolCallPart(id=self.id if tool_call_id is None else tool_call_id, name=self.name, args=self.args)


def execute(self, functions: dict[str, Any] | list[Tool] | None = None) -> Any:
"""Execute this individual tool call and return its result.
Expand Down Expand Up @@ -343,8 +352,7 @@ def from_dict_list(cls, tool_calls_dicts: list[dict[str, Any]]) -> "ToolCalls":
tool_calls = ToolCalls.from_dict_list(tool_calls_dict)
```
"""
tool_calls = [cls.ToolCall(**item) for item in tool_calls_dicts]
return cls(tool_calls=tool_calls)
return cls.model_validate(tool_calls_dicts)

@classmethod
def description(cls) -> str:
Expand All @@ -360,31 +368,107 @@ def format(self) -> list[dict[str, Any]]:
"tool_calls": [tool_call.format() for tool_call in self.tool_calls],
}

@classmethod
def parse_lm_response(cls, response: str | dict[str, Any]) -> "ToolCalls | None":
if not isinstance(response, dict):
return None
tool_calls = response.get("tool_calls")
if not tool_calls:
return None
return cls.model_validate(tool_calls)

def to_lm_parts(self, id_prefix: str | None = None) -> list[LMToolCallPart]:
return [
tool_call.to_lm_part(f"{id_prefix}_{idx}" if id_prefix is not None and tool_call.id is None else None)
for idx, tool_call in enumerate(self.tool_calls)
]

def with_call_ids(self, id_prefix: str) -> "ToolCalls":
tool_calls = [
tool_call if tool_call.id is not None else tool_call.model_copy(update={"id": f"{id_prefix}_{idx}"})
for idx, tool_call in enumerate(self.tool_calls)
]
return self.model_copy(update={"tool_calls": tool_calls})

@staticmethod
def _get_tool_call_value(item: Any, key: str, default: Any = None) -> Any:
if isinstance(item, dict):
return item.get(key, default)
return getattr(item, key, default)

@staticmethod
def _parse_tool_call_args(args: Any) -> Any:
if isinstance(args, str):
return json_repair.loads(args)
return args

@classmethod
def _normalized_tool_call(cls, name: Any, args: Any, tool_call_id: Any = None) -> dict[str, Any] | None:
if name is None:
return None
normalized = {"name": name, "args": cls._parse_tool_call_args(args)}
if tool_call_id:
normalized["id"] = tool_call_id
return normalized

@classmethod
def _normalize_native_tool_call(cls, item: Any) -> Any:
if not isinstance(item, dict) and hasattr(item, "model_dump"):
try:
dumped_item = item.model_dump()
except TypeError:
dumped_item = None
if isinstance(dumped_item, dict):
item = dumped_item

function = cls._get_tool_call_value(item, "function")
if function is not None:
name = cls._get_tool_call_value(function, "name")
arguments = cls._get_tool_call_value(function, "arguments", {})
normalized = cls._normalized_tool_call(name, arguments, cls._get_tool_call_value(item, "id"))
if normalized is not None:
return normalized

name = cls._get_tool_call_value(item, "name")
if cls._get_tool_call_value(item, "type") == "function_call" and name is not None:
arguments = cls._get_tool_call_value(item, "arguments", {})
normalized = cls._normalized_tool_call(
name,
arguments,
cls._get_tool_call_value(item, "call_id") or cls._get_tool_call_value(item, "id"),
)
if normalized is not None:
return normalized

args = cls._get_tool_call_value(item, "args")
if args is not None:
normalized = cls._normalized_tool_call(name, args, cls._get_tool_call_value(item, "id"))
if normalized is not None:
return normalized

return item

@pydantic.model_validator(mode="before")
@classmethod
def validate_input(cls, data: Any):
if isinstance(data, cls):
return data

# Handle case where data is a list of dicts with "name" and "args" keys
if isinstance(data, list) and all(
isinstance(item, dict) and "name" in item and "args" in item for item in data
):
return {"tool_calls": [cls.ToolCall(**item) for item in data]}
# Handle case where data is a dict
tool_calls_data = None
if isinstance(data, list):
tool_calls_data = data
elif isinstance(data, dict):
if "tool_calls" in data:
# Handle case where data is a dict with "tool_calls" key
tool_calls_data = data["tool_calls"]
if isinstance(tool_calls_data, list):
return {
"tool_calls": [
cls.ToolCall(**item) if isinstance(item, dict) else item for item in tool_calls_data
]
}
elif "name" in data and "args" in data:
# Handle case where data is a dict with "name" and "args" keys
return {"tool_calls": [cls.ToolCall(**data)]}
else:
normalized = cls._normalize_native_tool_call(data)
if isinstance(normalized, dict) and "name" in normalized and "args" in normalized:
return {"tool_calls": [normalized]}

if isinstance(tool_calls_data, list):
normalized = [cls._normalize_native_tool_call(item) for item in tool_calls_data]
if all(isinstance(item, dict) and "name" in item and "args" in item for item in normalized):
return {"tool_calls": normalized}

raise ValueError(f"Received invalid value for `dspy.ToolCalls`: {data}")

Expand Down
6 changes: 4 additions & 2 deletions tests/adapters/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
make_truncate_oldest_actions,
truncate_oldest_actions,
)
from dspy.adapters.types.tool import ToolCalls


def test_legacy_messages_key_still_constructs_history_frames():
Expand All @@ -18,18 +19,19 @@ def test_legacy_messages_key_still_constructs_history_frames():


def test_field_frames_round_trip():
tool_calls = ToolCalls.from_dict_list([{"name": "search", "args": {"query": "hello"}, "id": "call_0"}])
history = History(frames=[])

history.append_inputs({"question": "hi"})
history.append_outputs(
{"next_thought": "search first"},
{"next_thought": "search first", "tool_calls": tool_calls},
observations=[Observation(value="result", source="tool", call_id="call_0", name="search")],
)
history.append_output({"answer": "bye"})

assert isinstance(history.frames[0], HistoryFrame)
assert history.frames[0].inputs == {"question": "hi"}
assert history.frames[1].outputs == {"next_thought": "search first"}
assert history.frames[1].outputs == {"next_thought": "search first", "tool_calls": tool_calls}
assert history.frames[1].observations[0].call_id == "call_0"
assert history.frames[2].outputs == {"answer": "bye"}
assert history.frames[2].complete
Expand Down
49 changes: 49 additions & 0 deletions tests/adapters/test_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,55 @@ def test_tool_calls_format_from_dict_list():
assert result["tool_calls"][1]["function"]["name"] == "translate"


def test_tool_calls_preserve_call_ids_and_fill_missing_ids():
tool_calls = ToolCalls.from_dict_list(
[
{"name": "search", "args": {"query": "hello"}, "id": "call_search"},
{"name": "lookup", "args": {"key": "world"}},
]
).with_call_ids("call")

assert [tool_call.id for tool_call in tool_calls.tool_calls] == ["call_search", "call_1"]
assert [part.id for part in tool_calls.to_lm_parts()] == ["call_search", "call_1"]


def test_native_tool_response_preserves_call_ids():
class ToolSignature(dspy.Signature):
question: str = dspy.InputField()
tools: list[dspy.Tool] = dspy.InputField()
answer: str = dspy.OutputField()
tool_calls: dspy.ToolCalls = dspy.OutputField()

class ToolCallLM:
model = "openai/gpt-5-nano"
supports_function_calling = True
supports_reasoning = False
supports_response_schema = False
supported_params = frozenset()

def __call__(self, messages, **kwargs):
return [
{
"text": None,
"tool_calls": [
{
"function": {"arguments": '{"x":1,"y":"two"}', "name": "dummy_function"},
"id": "call_dummy",
"type": "function",
}
],
}
]

adapter = dspy.ChatAdapter(use_native_function_calling=True)
result = adapter(ToolCallLM(), {}, ToolSignature, [], {"question": "call it", "tools": [Tool(dummy_function)]})[0]

assert result["answer"] is None
assert result["tool_calls"] == ToolCalls.from_dict_list(
[{"name": "dummy_function", "args": {"x": 1, "y": "two"}, "id": "call_dummy"}]
)


def test_toolcalls_vague_match():
"""
Test that ToolCalls can parse the data with slightly off format:
Expand Down
Loading