Skip to content

Instantly share code, notes, and snippets.

@Jordanh1996
Last active April 26, 2026 13:38
Show Gist options
  • Select an option

  • Save Jordanh1996/69b0197a791b5680fdc26b8cae75509b to your computer and use it in GitHub Desktop.

Select an option

Save Jordanh1996/69b0197a791b5680fdc26b8cae75509b to your computer and use it in GitHub Desktop.
Reproduction: langchain-google-vertexai _merge_messages drops tool_result for empty ToolMessage content
"""Reproduction: _merge_messages in langchain-google-vertexai silently drops
tool_result when ToolMessage has empty content (content=[]).
Bug:
_merge_messages in langchain_google_vertexai/_anthropic_utils.py treats
ToolMessage(content=[]) as pre-formatted tool_result blocks because
Python's `all(predicate for x in [])` returns True on an empty iterable.
The empty list becomes HumanMessage([]), which then gets merged into the
next HumanMessage, silently dropping the tool_result. The Anthropic API
rejects the resulting messages with:
"tool_use ids were found without tool_result blocks immediately after"
The langchain-anthropic version already guards against this with an
`and curr.content` truthiness check. langchain-google-vertexai is
missing this guard.
Root cause (langchain_google_vertexai/_anthropic_utils.py ~line 395):
if isinstance(curr.content, list) and all(
isinstance(block, dict) and block.get("type") == "tool_result"
for block in curr.content # all() on [] -> True!
):
cleaned_content = _clean_content(curr.content)
curr = HumanMessage(cleaned_content) # HumanMessage([]) -> empty
Proposed fix:
Add `and curr.content` to the condition, matching langchain-anthropic:
if isinstance(curr.content, list) and curr.content and all(
...
):
Impact:
Any tool that returns empty results (MCP tools, search tools, etc.)
crashes the conversation. Observed 371+ errors in 7 days in production
with ChatAnthropicVertex on Vertex AI.
Usage:
# Message corruption tests only (no credentials needed):
pip install langchain-google-vertexai langchain-core
python repro_empty_tool_result.py
# Full end-to-end with actual API rejection (requires Vertex AI credentials):
export VERTEX_AI_PROJECT=your-gcp-project
export VERTEX_AI_LOCATION=us-east5
python repro_empty_tool_result.py --live
Exit codes:
0 = bug reproduced
1 = bug NOT reproduced (may be fixed in your version)
Tested with:
langchain-google-vertexai==3.2.2, langchain-core==1.2.22
"""
from __future__ import annotations
import argparse
import sys
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_google_vertexai._anthropic_utils import _merge_messages
def find_tool_results(merged: list) -> list[dict]:
"""Extract all tool_result blocks from merged messages."""
results = []
for m in merged:
if isinstance(m.content, list):
for block in m.content:
if isinstance(block, dict) and block.get("type") == "tool_result":
results.append(block)
return results
def test_empty_content_loses_tool_result() -> bool:
"""ToolMessage(content=[]) -> tool_result silently dropped."""
print("TEST 1: ToolMessage(content=[]) loses tool_result")
print("-" * 50)
messages = [
HumanMessage(content="Search for controls"),
AIMessage(content=[
{"type": "text", "text": "Let me search for that."},
{"type": "tool_use", "id": "call_1", "name": "field_search",
"input": {"query": "controls"}},
]),
ToolMessage(content=[], tool_call_id="call_1"),
]
merged = _merge_messages(messages)
tool_results = find_tool_results(merged)
print(f" Input: ToolMessage(content=[], tool_call_id='call_1')")
print(f" Merged messages: {len(merged)}")
print(f" tool_result blocks found: {len(tool_results)}")
if len(tool_results) == 0:
print(" BUG: tool_result was silently dropped!")
return True
else:
print(f" OK: tool_result present (tool_use_id={tool_results[0]['tool_use_id']})")
return False
def test_empty_content_merged_away() -> bool:
"""The empty tool_result disappears when followed by a HumanMessage."""
print()
print("TEST 2: Empty tool_result merges into next HumanMessage and vanishes")
print("-" * 50)
messages = [
HumanMessage(content="Search for controls"),
AIMessage(content=[
{"type": "text", "text": "Let me search."},
{"type": "tool_use", "id": "call_1", "name": "field_search",
"input": {"query": "controls"}},
]),
ToolMessage(content=[], tool_call_id="call_1"),
HumanMessage(content="What did you find?"),
]
merged = _merge_messages(messages)
tool_results = find_tool_results(merged)
print(f" Input: [Human, AI+tool_use, ToolMessage([]), Human]")
print(f" Merged messages: {len(merged)}")
for i, m in enumerate(merged):
if isinstance(m.content, list):
types = [b.get("type", "?") for b in m.content if isinstance(b, dict)]
print(f" [{i}] {type(m).__name__}: blocks={types}")
else:
print(f" [{i}] {type(m).__name__}: {str(m.content)[:50]}")
if len(tool_results) == 0:
print(" BUG: tool_result was merged away -- only user text remains")
return True
else:
print(" OK: tool_result preserved")
return False
def test_nonempty_content_works() -> bool:
"""Sanity check: non-empty content still produces tool_result."""
print()
print("TEST 3: Non-empty content works correctly (sanity check)")
print("-" * 50)
messages = [
HumanMessage(content="Search"),
AIMessage(content=[
{"type": "text", "text": "Searching..."},
{"type": "tool_use", "id": "call_1", "name": "search",
"input": {"q": "test"}},
]),
ToolMessage(content="Found 3 results.", tool_call_id="call_1"),
]
merged = _merge_messages(messages)
tool_results = find_tool_results(merged)
if len(tool_results) == 1:
print(f" OK: tool_result present with content='{tool_results[0]['content']}'")
return False
else:
print(f" UNEXPECTED: {len(tool_results)} tool_results")
return True
def test_python_all_empty() -> bool:
"""Demonstrates the root cause: all() on empty iterable returns True."""
print()
print("TEST 4: Python's all() on empty iterable (root cause)")
print("-" * 50)
result = all(
isinstance(block, dict) and block.get("type") == "tool_result"
for block in []
)
print(f" all(predicate for x in []) = {result}")
print(f" This causes content=[] to be treated as 'pre-formatted tool_result blocks'")
return result is True
def test_live_api_rejection() -> bool:
"""Send the broken messages directly to the Anthropic API to prove the 400.
This bypasses _merge_messages and sends exactly the malformed payload
that the bug produces, proving the API rejects it.
"""
import os
print()
print("TEST 5: Live API rejection (direct Anthropic API call)")
print("-" * 50)
# Try Vertex AI first, fall back to direct Anthropic
project = os.environ.get("VERTEX_AI_PROJECT")
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not project and not api_key:
print(" SKIP: set VERTEX_AI_PROJECT or ANTHROPIC_API_KEY to run this test")
return False
try:
import anthropic
except ImportError:
print(" SKIP: anthropic package not installed")
return False
if project:
from anthropic import AnthropicVertex
location = os.environ.get("VERTEX_AI_LOCATION", "us-east5")
client = AnthropicVertex(project_id=project, region=location)
print(f" Using Vertex AI ({project}/{location})")
else:
client = anthropic.Anthropic(api_key=api_key)
print(f" Using direct Anthropic API")
# This is exactly the message sequence _merge_messages produces
# when a ToolMessage has content=[]: the tool_result vanishes,
# and the API sees tool_use without tool_result.
messages = [
{"role": "user", "content": "Search for compliance controls"},
{"role": "assistant", "content": [
{"type": "text", "text": "Let me search."},
{"type": "tool_use", "id": "call_1", "name": "field_search",
"input": {"query": "controls"}},
]},
# This is what _merge_messages produces: the tool_result is GONE,
# only the follow-up human text remains.
{"role": "user", "content": "What did you find?"},
]
print(f" Sending: [user, assistant+tool_use, user(no tool_result)]")
try:
response = client.messages.create(
model=os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-5"),
max_tokens=100,
messages=messages,
)
print(f" UNEXPECTED: API accepted the request")
return False
except anthropic.BadRequestError as e:
error_str = str(e)
if "tool_use" in error_str:
print(f" API rejected with BadRequestError (400):")
print(f" {error_str[:200]}")
return True
else:
print(f" BadRequestError but different reason: {error_str[:200]}")
return False
except Exception as e:
print(f" UNEXPECTED error: {type(e).__name__}: {str(e)[:200]}")
return False
def main() -> None:
parser = argparse.ArgumentParser(
description="Reproduce _merge_messages empty ToolMessage content bug"
)
parser.add_argument(
"--live", action="store_true",
help="Run live API test (requires VERTEX_AI_PROJECT env var)",
)
args = parser.parse_args()
print("Reproduction: _merge_messages drops tool_result for empty ToolMessage")
print("=" * 60)
try:
from importlib.metadata import version
print(f"langchain-google-vertexai {version('langchain-google-vertexai')}")
print(f"langchain-core {version('langchain-core')}")
except Exception:
pass
print()
results = {
"Empty content drops tool_result": test_empty_content_loses_tool_result(),
"Empty content merged away": test_empty_content_merged_away(),
"Non-empty content works": test_nonempty_content_works(),
"all() on empty iterable": test_python_all_empty(),
}
if args.live:
results["Live API rejection"] = test_live_api_rejection()
print()
print("=" * 60)
print("SUMMARY")
print("=" * 60)
any_bug = False
for name, is_bug in results.items():
status = "BUG" if is_bug else "OK"
print(f" [{status:3s}] {name}")
if is_bug:
any_bug = True
if any_bug:
print()
print("Root cause: all() on empty iterable returns True.")
print("Fix: add `and curr.content` guard to line ~395 of")
print(" langchain_google_vertexai/_anthropic_utils.py")
print(" (matching what langchain-anthropic already does)")
sys.exit(0)
else:
print()
print("Bug not reproduced -- may be fixed in your version.")
sys.exit(1)
if __name__ == "__main__":
main()
@Jordanh1996

Copy link
Copy Markdown
Author

Output (langchain-google-vertexai==3.2.2, langchain-core==1.2.30)

Run with --live flag using Claude on Vertex AI:

Reproduction: _merge_messages drops tool_result for empty ToolMessage
============================================================
langchain-google-vertexai 3.2.2
langchain-core 1.2.30

TEST 1: ToolMessage(content=[]) loses tool_result
--------------------------------------------------
  Input: ToolMessage(content=[], tool_call_id='call_1')
  Merged messages: 3
  tool_result blocks found: 0
  BUG: tool_result was silently dropped!

TEST 2: Empty tool_result merges into next HumanMessage and vanishes
--------------------------------------------------
  Input: [Human, AI+tool_use, ToolMessage([]), Human]
  Merged messages: 3
    [0] HumanMessage: Search for controls
    [1] AIMessage: blocks=['text', 'tool_use']
    [2] HumanMessage: blocks=['text']
  BUG: tool_result was merged away -- only user text remains

TEST 3: Non-empty content works correctly (sanity check)
--------------------------------------------------
  OK: tool_result present with content='Found 3 results.'

TEST 4: Python's all() on empty iterable (root cause)
--------------------------------------------------
  all(predicate for x in []) = True
  This causes content=[] to be treated as 'pre-formatted tool_result blocks'

TEST 5: Live API rejection (direct Anthropic API call)
--------------------------------------------------
  Using Vertex AI
  Sending: [user, assistant+tool_use, user(no tool_result)]
  API rejected with BadRequestError (400):
    messages.2: `tool_use` ids were found without `tool_result` blocks
    immediately after: call_1. Each `tool_use` block must have a
    corresponding `tool_result` block in the next message.

============================================================
SUMMARY
============================================================
  [BUG] Empty content drops tool_result
  [BUG] Empty content merged away
  [OK ] Non-empty content works
  [BUG] all() on empty iterable
  [BUG] Live API rejection

Fix

One-line change in langchain_google_vertexai/_anthropic_utils.py ~line 395:

- if isinstance(curr.content, list) and all(
+ if isinstance(curr.content, list) and curr.content and all(
      isinstance(block, dict) and block.get("type") == "tool_result"
      for block in curr.content
  ):

This matches the guard already present in langchain-anthropic's _merge_messages.

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