Last active
April 26, 2026 13:38
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """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() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Output (
langchain-google-vertexai==3.2.2,langchain-core==1.2.30)Run with
--liveflag using Claude on Vertex AI:Fix
One-line change in
langchain_google_vertexai/_anthropic_utils.py~line 395:This matches the guard already present in
langchain-anthropic's_merge_messages.