feat(history): render native tool-call messages - #55
Conversation
Greptile SummaryThis PR extends the history rendering pipeline to emit native tool-call and tool-result messages when the adapter's
Confidence Score: 3/5The 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
Important Files Changed
Sequence DiagramsequenceDiagram
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
Reviews (1): Last reviewed commit: "feat(history): render native tool-call m..." | Re-trigger Greptile |
| 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))) |
There was a problem hiding this comment.
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.
| def _history_to_lm_messages(self, signature: type[Signature], history: History) -> list[LMMessage]: | ||
| return history.to_lm_messages(self, signature) |
There was a problem hiding this comment.
_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.
| 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 _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) |
There was a problem hiding this comment.
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.
Summary
ToolCallsas assistant native tool-call messages when native function calling is activeStack
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