diff --git a/dspy/adapters/base.py b/dspy/adapters/base.py index 5ed70c3841..90a4e78e69 100644 --- a/dspy/adapters/base.py +++ b/dspy/adapters/base.py @@ -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. diff --git a/dspy/adapters/types/history.py b/dspy/adapters/types/history.py index ae966f654d..cd6237e100 100644 --- a/dspy/adapters/types/history.py +++ b/dspy/adapters/types/history.py @@ -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 @@ -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): + 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] diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index ee88e2db34..1d7ef3b696 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -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 @@ -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) def execute(self, functions: dict[str, Any] | list[Tool] | None = None) -> Any: """Execute this individual tool call and return its result. @@ -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: @@ -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}") diff --git a/tests/adapters/test_history.py b/tests/adapters/test_history.py index 3e7594c9a3..d56be4f108 100644 --- a/tests/adapters/test_history.py +++ b/tests/adapters/test_history.py @@ -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(): @@ -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 diff --git a/tests/adapters/test_tool.py b/tests/adapters/test_tool.py index cfcffe0947..f91d254f63 100644 --- a/tests/adapters/test_tool.py +++ b/tests/adapters/test_tool.py @@ -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: