diff --git a/dspy/adapters/base.py b/dspy/adapters/base.py index 90a4e78e69..c20335435d 100644 --- a/dspy/adapters/base.py +++ b/dspy/adapters/base.py @@ -96,7 +96,13 @@ def plan_fields( value = inputs[name] if field.annotation == History: prompt_signature = prompt_signature.delete(name) - messages.extend(value.to_lm_messages(self, prompt_signature)) + messages.extend( + value.to_lm_messages( + self, + prompt_signature, + use_native_tool_calls=self._uses_native_tool_calls(prompt_signature, lm), + ) + ) inputs.pop(name, None) history_has_open_episode = value.has_open_episode() elif field.annotation == Image: @@ -216,6 +222,13 @@ def _last_user_message_index(self, messages: list[LMMessage]) -> int | None: def _history_to_lm_messages(self, signature: type[Signature], history: History) -> list[LMMessage]: return history.to_lm_messages(self, signature) + def _uses_native_tool_calls(self, signature: type[Signature], lm: BaseLM) -> bool: + return ( + self.use_native_function_calling + and getattr(lm, "supports_function_calling", False) + and self._get_tool_call_output_field_name(signature) is not None + ) + def _image_to_lm_part(self, image: Image) -> LMImagePart: source = image.url if source.startswith("data:") and "," in source: diff --git a/dspy/adapters/types/history.py b/dspy/adapters/types/history.py index cd6237e100..cb8b48444c 100644 --- a/dspy/adapters/types/history.py +++ b/dspy/adapters/types/history.py @@ -4,7 +4,7 @@ from pydantic import Field, model_validator from dspy.adapters.types.tool import ToolCalls -from dspy.core.types import LMMessage +from dspy.core.types import LMMessage, LMTextPart, LMToolResultPart class Observation(pydantic.BaseModel): @@ -133,9 +133,9 @@ def has_open_episode(self) -> bool: last_boundary = "output" return last_boundary == "input" - def to_lm_messages(self, adapter: Any, signature: type[Any]) -> list[LMMessage]: + def to_lm_messages(self, adapter: Any, signature: type[Any], *, use_native_tool_calls: bool = False) -> list[LMMessage]: messages: list[LMMessage] = [] - for entry in self.frames: + for frame_idx, entry in enumerate(self.frames): frame = self._entry_to_frame(signature, entry) if frame.inputs: content = adapter.format_user_message_content(signature, frame.inputs) @@ -143,11 +143,43 @@ def to_lm_messages(self, adapter: Any, signature: type[Any]) -> list[LMMessage]: messages.append(self._content_message("user", content)) if frame.outputs: - messages.append(self._content_message("assistant", self._format_outputs(adapter, signature, frame.outputs))) - if frame.observations: + messages.extend(self._format_frame_outputs(adapter, signature, frame, frame_idx, use_native_tool_calls)) + elif frame.observations: messages.append(self._content_message("user", self._format_observations(frame.observations))) return messages + def _format_frame_outputs( + self, + adapter: Any, + signature: type[Any], + frame: HistoryFrame, + frame_idx: int, + use_native_tool_calls: bool, + ) -> list[LMMessage]: + tool_calls = self._find_tool_calls(frame.outputs) + if use_native_tool_calls and tool_calls is not None: + parts = [] + content = self._format_frame_native_content(adapter, signature, frame.outputs) + if content: + parts.append(LMTextPart(text=content)) + parts.extend(tool_calls.to_lm_parts(id_prefix=f"call_{frame_idx}")) + + messages = [LMMessage(role="assistant", parts=parts)] + non_native_observations = [] + for observation in frame.observations: + if observation.call_id is not None: + messages.append(self._native_tool_observation(observation)) + else: + non_native_observations.append(observation) + if non_native_observations: + messages.append(self._content_message("user", self._format_observations(non_native_observations))) + return messages + + messages = [self._content_message("assistant", self._format_outputs(adapter, signature, frame.outputs))] + if frame.observations: + messages.append(self._content_message("user", self._format_observations(frame.observations))) + return messages + @staticmethod def _entry_to_frame(signature: type[Any], entry: HistoryEntry) -> HistoryFrame: if isinstance(entry, HistoryFrame): @@ -186,6 +218,21 @@ def _format_outputs(self, adapter: Any, signature: type[Any], outputs: dict[str, sections.append("[[ ## completed ## ]]") return "\n\n".join(section for section in sections if section) + @staticmethod + def _find_tool_calls(outputs: dict[str, Any]) -> ToolCalls | None: + for value in outputs.values(): + if isinstance(value, ToolCalls): + return value + return None + + def _format_frame_native_content(self, adapter: Any, signature: type[Any], outputs: dict[str, Any]) -> str | None: + non_tool_outputs = {key: value for key, value in outputs.items() if not isinstance(value, ToolCalls)} + if not non_tool_outputs: + return None + if len(non_tool_outputs) == 1: + return str(next(iter(non_tool_outputs.values()))) + return self._format_outputs(adapter, signature, non_tool_outputs) + def _format_observations(self, observations: list[Observation]) -> str: rendered = [] for idx, observation in enumerate(observations): @@ -214,6 +261,19 @@ def _format_observation_content(content: Any) -> str: return "\n".join(str(item) for item in content) return str(content) + def _native_tool_observation(self, observation: Observation) -> LMMessage: + return LMMessage( + role="tool", + parts=[ + LMToolResultPart( + call_id=observation.call_id, + name=observation.name, + content=[LMTextPart(text=self._format_observation_content(observation.value))], + is_error=observation.is_error, + ) + ], + ) + @staticmethod def _has_content(content: Any) -> bool: if isinstance(content, str): diff --git a/tests/adapters/test_history_lm_messages.py b/tests/adapters/test_history_lm_messages.py new file mode 100644 index 0000000000..d786e5c487 --- /dev/null +++ b/tests/adapters/test_history_lm_messages.py @@ -0,0 +1,60 @@ +import json + +import dspy +from dspy.adapters.types.history import History, Observation +from dspy.adapters.types.tool import Tool, ToolCalls + + +def add(a: int, b: int) -> int: + return a + b + + +class NativeToolLM: + model = "openai/gpt-5-nano" + supported_params = frozenset() + supports_function_calling = True + supports_reasoning = False + supports_response_schema = False + + def __call__(self, messages, **kwargs): + self.messages = messages + self.kwargs = kwargs + return [{"text": "[[ ## next_thought ## ]]\nDone.\n\n[[ ## completed ## ]]"}] + + +def test_history_renders_native_tool_messages_through_lm_message_path(): + adapter = dspy.ChatAdapter(use_native_function_calling=True) + signature = ( + dspy.Signature({}, "Do the task.") + .append("question", dspy.InputField(), type_=str) + .append("history", dspy.InputField(), type_=dspy.History) + .append("tools", dspy.InputField(), type_=list[dspy.Tool]) + .append("next_thought", dspy.OutputField(), type_=str) + .append("tool_calls", dspy.OutputField(), type_=dspy.ToolCalls) + ) + history = History(frames=[]) + history.append_inputs({"question": "What is 1+2?"}) + history.append_outputs( + { + "next_thought": "I should add the two numbers.", + "tool_calls": ToolCalls.from_dict_list( + [{"name": "add", "args": {"a": 1, "b": 2}, "id": "call_add"}] + ), + }, + observations=[Observation(value=3, source="tool", call_id="call_add", name="add")], + ) + + lm = NativeToolLM() + adapter(lm, {}, signature, [], {"question": "What is 1+2?", "history": history, "tools": [Tool(add)]}) + + assistant_message = next(message for message in lm.messages if message["role"] == "assistant") + tool_message = next(message for message in lm.messages if message["role"] == "tool") + final_user_message = lm.messages[-1] + + assert assistant_message["content"] == "I should add the two numbers." + assert assistant_message["tool_calls"][0]["id"] == "call_add" + assert assistant_message["tool_calls"][0]["function"]["name"] == "add" + assert json.loads(assistant_message["tool_calls"][0]["function"]["arguments"]) == {"a": 1, "b": 2} + assert tool_message == {"role": "tool", "content": "3", "tool_call_id": "call_add", "name": "add"} + assert "What is 1+2?" not in final_user_message["content"] + assert "Respond with the corresponding output fields" in final_user_message["content"]