Skip to content

feat(tool): preserve native tool call ids - #53

Open
isaacbmiller wants to merge 1 commit into
isaac/react-v2-pr2-history-formattingfrom
isaac/react-v2-clean
Open

feat(tool): preserve native tool call ids#53
isaacbmiller wants to merge 1 commit into
isaac/react-v2-pr2-history-formattingfrom
isaac/react-v2-clean

Conversation

@isaacbmiller

@isaacbmiller isaacbmiller commented May 20, 2026

Copy link
Copy Markdown

Summary

  • preserve provider tool-call IDs in ToolCalls
  • normalize OpenAI chat and responses tool-call wire shapes at the ToolCalls boundary
  • expose conversion from ToolCalls to normalized LM tool-call parts
  • preserve call IDs when adapter responses are parsed back into ToolCalls

Stack

Validation

  • uv run --extra dev pytest -q tests/adapters/test_history.py tests/adapters/test_tool.py -k "field_frames_round_trip or toolcalls_vague_match or tool_calls_preserve_call_ids or native_tool_response_preserves_call_ids"
  • Full stack focused validation command listed in feat(history): add typed history frames #51.

@isaacbmiller
isaacbmiller force-pushed the isaac/react-v2-clean branch from 2f13e38 to d9bc967 Compare May 22, 2026 20:24
@isaacbmiller isaacbmiller changed the title Isaac/react v2 clean feat(tool): preserve native tool call ids May 22, 2026
@isaacbmiller
isaacbmiller changed the base branch from main to isaac/react-v2-pr2-history-formatting May 22, 2026 20:26
@isaacbmiller
isaacbmiller marked this pull request as ready for review May 22, 2026 20:28
@greptile-apps

greptile-apps Bot commented May 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR threads native provider tool-call IDs through the ToolCalls type so they survive the full round-trip: LM response → ToolCalls field → History frame → LMToolCallPart list. It also adds normalization helpers that accept both the OpenAI chat wire shape ({"type":"function","function":{...},"id":...}) and the responses API shape ({"type":"function_call","name":...,"call_id":...}) at the ToolCalls validation boundary.

  • ToolCall gains an optional id field; validate_input / _normalize_native_tool_call unify several provider wire shapes into DSPy's internal {name, args, id} representation, and with_call_ids / to_lm_parts provide ID-assignment and export helpers.
  • HistoryFrame adds a post-init validator that converts serialized {"tool_calls":[...]} dicts back into ToolCalls instances, enabling clean deserialization from JSON history.
  • base.py is updated to forward call.id when constructing ToolCalls from the adapter output loop.

Confidence Score: 4/5

Safe to merge; all changed paths are covered by focused tests and the normalization logic handles all targeted wire shapes correctly.

The normalization logic in _normalize_native_tool_call is well-structured and covers OpenAI chat format, responses-API format, and DSPy native format with correct fallthrough ordering. The to_lm_part or-based fallback is a minor style inconsistency rather than a present defect. The exact-key check in _normalize_tool_calls_outputs is intentionally conservative and poses only a forward-compatibility concern, not a current breakage.

dspy/adapters/types/tool.py — the validate_input refactor is the most logic-dense change and warrants a close read; the rest of the diff is straightforward.

Important Files Changed

Filename Overview
dspy/adapters/types/tool.py Core change: adds id field to ToolCall, introduces normalization helpers for OpenAI chat/responses wire shapes, and refactors validate_input + from_dict_list to use the new normalizer. One minor logic issue in to_lm_part with or vs explicit None check.
dspy/adapters/types/history.py Adds _normalize_tool_calls_outputs model validator to convert serialized {"tool_calls": [...]} dicts back to ToolCalls instances; the exact-key-set check is intentionally conservative but may silently skip normalization if the serialization format gains extra keys.
dspy/adapters/base.py Minimal one-line change to pass call.id through to ToolCalls.from_dict_list; straightforward and correct.
tests/adapters/test_tool.py New tests cover ID preservation with with_call_ids, to_lm_parts, and the full adapter round-trip via a mock LM; coverage is adequate for the changed paths.
tests/adapters/test_history.py Updates the round-trip test to carry a ToolCalls value in outputs and validates its identity after the frame is stored; no issues found.

Sequence Diagram

sequenceDiagram
    participant LM as LM (provider)
    participant Adapter as base.Adapter
    participant TC as ToolCalls
    participant Hist as HistoryFrame

    LM->>Adapter: LMOutput with tool_calls list
    Note over Adapter: output.tool_calls → [LMToolCallPart(id="call_1", ...)]
    Adapter->>TC: "from_dict_list([{name, args, id:"call_1"}])"
    Note over TC: validate_input → _normalize_native_tool_call
    Note over TC: OpenAI / responses-API / DSPy format → {name, args, id}
    TC-->>Adapter: "ToolCalls(tool_calls=[ToolCall(id="call_1", ...)])"
    Adapter-->>Adapter: "value[tool_call_field] = ToolCalls(...)"

    Note over Hist: HistoryFrame constructed with outputs dict
    Adapter->>Hist: "HistoryFrame(outputs={...ToolCalls(...)})"
    Note over Hist: _normalize_tool_calls_outputs validator
    Note over Hist: passes through ToolCalls instances unchanged
    Note over Hist: converts raw {"tool_calls":[...]} dicts → ToolCalls

    Note over TC: Downstream rendering
    TC->>TC: with_call_ids("prefix") fills missing IDs
    TC->>TC: "to_lm_parts() → [LMToolCallPart(id="call_1", ...)]"
Loading

Reviews (1): Last reviewed commit: "feat(tool): preserve native tool call id..." | Re-trigger Greptile

Comment on lines +285 to +286
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)

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)

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:

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