AI & Agent Dev Bug Sandbox logo
AI & Agent Dev Bug Sandbox
Back to Radar

Convert_to_openai_messages Drops Invalid Tool Calls From Exported Conversations

The convert_to_openai_messages utility in langchain-core omits AIMessage.invalid_tool_calls from the converted OpenAI message, while the corresponding ToolMessage replies are retained, leading to orphaned tool_call_id references in exported conversations.

mediumConfidence 90%LangChainAffected V1.6.8

Origin Analysis

The conversion logic for AIMessage only reads message.tool_calls (valid tool calls) and never checks message.invalid_tool_calls, so any failed tool calls are silently dropped from the assistant message. This creates an inconsistency because the subsequent ToolMessage still references the dropped call ID.
1. Create an AIMessage with empty content and invalid_tool_calls containing a single entry with name='weather', args='{"city":', id='failed_call', error='Invalid JSON'. 2. Create a ToolMessage with content='Invalid arguments' and tool_call_id='failed_call'. 3. Call convert_to_openai_messages([message, reply]). 4. Observe that the assistant message dict is {'role': 'assistant', 'content': ''} with no tool_calls key, while the tool message retains the reference to 'failed_call'.

Fixing Code Block

from typing import Sequence, List, Dict, Any, Optional, Union from langchain_core.messages import ( BaseMessage, HumanMessage, AIMessage, SystemMessage, ToolMessage, FunctionMessage, ChatMessage ) def _format_invalid_tool_call(call: Any) -> dict: """Format an InvalidToolCall object or dict for OpenAI messages.""" if isinstance(call, dict): call_id = call.get("id") name = call.get("name") args = call.get("args", "") else: call_id = getattr(call, "id", None) name = getattr(call, "name", "") args = getattr(call, "args", "") return { "id": call_id, "type": "function", "function": { "name": name, "arguments": args, }, } def convert_to_openai_message(message: BaseMessage) -> dict: """Convert a LangChain message to OpenAI format.""" if isinstance(message, HumanMessage): return {"role": "user", "content": message.content} elif isinstance(message, AIMessage): ai_dict: dict = {"role": "assistant", "content": message.content} tool_calls: List[dict] = [] if message.tool_calls: tool_calls.extend([ { "id": tool_call["id"], "type": "function", "function": { "name": tool_call["name"], "arguments": tool_call["args"], }, } for tool_call in message.tool_calls ]) if message.invalid_tool_calls: tool_calls.extend(_format_invalid_tool_call(call) for call in message.invalid_tool_calls) if tool_calls: ai_dict["tool_calls"] = tool_calls # Preserve other standard AIMessage fields if present if getattr(message, "audio", None): ai_dict["audio"] = message.audio if getattr(message, "function_call", None): ai_dict["function_call"] = message.function_call return ai_dict elif isinstance(message, SystemMessage): return {"role": "system", "content": message.content} elif isinstance(message, ToolMessage): return { "role": "tool", "content": message.content, "tool_call_id": message.tool_call_id, } elif isinstance(message, FunctionMessage): return {"role": "function", "content": message.content} elif isinstance(message, ChatMessage): return {"role": message.role, "content": message.content} else: raise ValueError(f"Unknown message type: {type(message)}") def convert_to_openai_messages(messages: Sequence[BaseMessage]) -> List[dict]: return [convert_to_openai_message(message) for message in messages]
The fix adds handling for AIMessage.invalid_tool_calls in the convert_to_openai_message function. It formats each invalid call into the OpenAI tool_call structure using the call's id, name, and original arg string, and merges them with any valid tool_calls. This ensures that the assistant message carries all tool call metadata, preventing orphan references in subsequent ToolMessages.

Edge Case Audit

Merging invalid_tool_calls into the tool_calls list means that if the converted messages are sent directly to the OpenAI API, the API may reject the request because the arguments field contains invalid JSON. This could break existing pipelines that relied on the old behavior of stripping invalid calls before sending. Additionally, if an AIMessage has both valid and invalid tool calls, they are combined into a single list, which may confuse downstream consumers expecting only valid calls. Before deploying, test with real API calls. Rollback: revert the code change or pin langchain-core to the previous version if unexpected API errors occur.

Ecosystem Topology