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
15 changes: 14 additions & 1 deletion dspy/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Comment on lines 222 to 223

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 _history_to_lm_messages is still present but now silently uses the default use_native_tool_calls=False. Any subclass or external caller that invokes this method will never get native tool-call rendering even when _uses_native_tool_calls would return True. The method is also unused by the main execution path (plan_fields calls to_lm_messages directly). Updating the signature keeps the helper consistent with the new behaviour.

Suggested change
def _history_to_lm_messages(self, signature: type[Signature], history: History) -> list[LMMessage]:
return history.to_lm_messages(self, signature)
def _history_to_lm_messages(self, signature: type[Signature], history: History, lm: BaseLM | None = None) -> list[LMMessage]:
use_native = self._uses_native_tool_calls(signature, lm) if lm is not None else False
return history.to_lm_messages(self, signature, use_native_tool_calls=use_native)


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:
Expand Down
70 changes: 65 additions & 5 deletions dspy/adapters/types/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -133,21 +133,53 @@ 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)
if self._has_content(content):
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)))
Comment on lines +159 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Stranded tool calls when ToolCall.id and Observation.call_id are both None

to_lm_parts generates fallback IDs (e.g. call_0_0) for tool calls that have id=None. But the observation-matching loop only routes observations into native tool messages when observation.call_id is not None. If both are None, the result is an assistant message with a tool-call part carrying an auto-generated ID and no matching tool-result message — a sequence that OpenAI and Anthropic reject with an API error.

A concrete failure: history recorded before native calling was enabled stores ToolCall(id=None) + Observation(call_id=None). When that history is replayed with use_native_tool_calls=True, the assistant message gets id="call_0_0" but there is no tool_call_id="call_0_0" result, so the next call to the provider fails.

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):
Expand Down Expand Up @@ -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)
Comment on lines +228 to +234

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 Inconsistent formatting for single vs. multiple non-tool outputs

When there is exactly one non-ToolCalls output the method returns a bare str(), bypassing DSPy field markers entirely. When there are two or more it falls through to _format_outputs, which wraps each value in [[ ## field_name ## ]] delimiters. A frame with one non-tool field (e.g. next_thought) renders as plain text, but if a second reasoning field is added it switches to structured DSPy format without any explicit signal to callers. This can silently change how historical assistant turns are presented to the LLM as a signature evolves.


def _format_observations(self, observations: list[Observation]) -> str:
rendered = []
for idx, observation in enumerate(observations):
Expand Down Expand Up @@ -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):
Expand Down
60 changes: 60 additions & 0 deletions tests/adapters/test_history_lm_messages.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading