Skip to content

feat(history): render native tool-call messages - #55

Open
isaacbmiller wants to merge 1 commit into
isaac/react-v2-cleanfrom
isaac/react-v2-pr4-native-history
Open

feat(history): render native tool-call messages#55
isaacbmiller wants to merge 1 commit into
isaac/react-v2-cleanfrom
isaac/react-v2-pr4-native-history

Conversation

@isaacbmiller

Copy link
Copy Markdown

Summary

  • render historical ToolCalls as assistant native tool-call messages when native function calling is active
  • render matching observations as provider tool-result messages
  • keep non-native history formatting unchanged

Stack

Validation

  • uv run --extra dev pytest -q tests/adapters/test_history_lm_messages.py tests/adapters/test_history_formatting.py tests/adapters/test_history.py
  • Full stack focused validation command listed in feat(history): add typed history frames #51.

@greptile-apps

greptile-apps Bot commented May 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR extends the history rendering pipeline to emit native tool-call and tool-result messages when the adapter's use_native_function_calling flag is on and the LM supports function calling, keeping non-native formatting unchanged.

  • History.to_lm_messages gains a use_native_tool_calls flag; when set, ToolCalls outputs are rendered as LMToolCallPart assistant parts and matching Observations (those with call_id) are rendered as role=tool result messages.
  • Adapter.plan_fields computes _uses_native_tool_calls and threads it through to to_lm_messages; _history_to_lm_messages is left untouched and is now silently inconsistent.
  • A new test covers the happy path with explicit tool-call IDs but does not exercise the ID-less edge case.

Confidence Score: 3/5

The core happy path (tool calls with explicit IDs and matching observations) works and is tested, but replaying history where tool calls were stored without IDs emits an assistant message with unmatched tool-call parts that providers reject at the API level.

The central logic in _format_frame_outputs assigns auto-generated IDs to id=None tool calls in the assistant message while simultaneously routing their observations (also call_id=None) to a plain user text message. This leaves the provider-facing sequence with a tool call that has no matching tool result — a sequence OpenAI and Anthropic reject. The bug fires in a specific migration scenario, but to_lm_parts explicitly adds fallback-ID logic, so the inconsistency appears unintentional.

dspy/adapters/types/history.py — specifically _format_frame_outputs (observation-matching loop) and _format_frame_native_content (single vs. multi-output formatting inconsistency). dspy/adapters/base.py_history_to_lm_messages is now stale.

Important Files Changed

Filename Overview
dspy/adapters/types/history.py Adds native tool-call rendering for history frames; contains a mismatch between auto-generated tool-call IDs and observation matching logic, plus an inconsistency in single vs. multi-output formatting.
dspy/adapters/base.py Adds _uses_native_tool_calls helper and threads it into plan_fields; _history_to_lm_messages is now stale since it doesn't accept or forward the new flag.
tests/adapters/test_history_lm_messages.py New test covering the happy path (tool calls and observations with explicit IDs); does not cover the ID-less edge case that triggers the bug.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Adapter as Adapter.plan_fields
    participant History as History.to_lm_messages
    participant FFO as _format_frame_outputs

    Caller->>Adapter: __call__(lm, sig, inputs)
    Adapter->>Adapter: _uses_native_tool_calls(sig, lm)
    Note right of Adapter: checks use_native_function_calling + lm.supports_function_calling + ToolCalls output field
    Adapter->>History: "to_lm_messages(adapter, sig, use_native_tool_calls=True/False)"
    loop each HistoryFrame
        History->>FFO: _format_frame_outputs(frame, frame_idx, use_native)
        alt "use_native=True AND ToolCalls in outputs"
            FFO->>FFO: _find_tool_calls(outputs)
            FFO->>FFO: _format_frame_native_content() to text
            FFO-->>History: "LMMessage(role=assistant, parts=[LMTextPart, LMToolCallPart...])"
            loop observations
                alt observation.call_id is not None
                    FFO-->>History: "LMMessage(role=tool, LMToolResultPart)"
                else call_id is None
                    FFO-->>History: "LMMessage(role=user, observations_text)"
                end
            end
        else "use_native=False OR no ToolCalls"
            FFO-->>History: "LMMessage(role=assistant, text) + LMMessage(role=user, observations)"
        end
    end
    History-->>Adapter: list[LMMessage]
    Adapter->>Adapter: render_request calls format() with history already removed
    Adapter-->>Caller: parsed outputs
Loading

Reviews (1): Last reviewed commit: "feat(history): render native tool-call m..." | Re-trigger Greptile

Comment on lines +159 to +175
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)))

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.

Comment thread dspy/adapters/base.py
Comment on lines 222 to 223
def _history_to_lm_messages(self, signature: type[Signature], history: History) -> list[LMMessage]:
return history.to_lm_messages(self, signature)

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)

Comment on lines +228 to +234
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)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant