langchain - 💡(How to fix) Fix Duplicate tool_calls in content_blocks with OpenAI Responses API streaming [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
langchain-ai/langchain#36984Fetched 2026-04-25 06:03:15
View on GitHub
Comments
1
Participants
2
Timeline
3
Reactions
0
Timeline (top)
closed ×1commented ×1labeled ×1

When using ChatOpenAI(use_responses_api=True, streaming=True), the content_blocks property includes duplicate tool_calls - both partial fc_ prefixed blocks (with empty args) from streaming AND final call_ prefixed blocks (with full args).

What happens:

  1. OpenAI Responses API emits partial function calls during streaming with fc_ prefix IDs and empty args
  2. When streaming completes, tool_calls property contains final call_ prefix IDs with full args
  3. content_blocks property dedupes by ID only
  4. Since fc_xxx != call_xxx, both get included
  5. When serializing with content_blocks and later deserializing, both versions persist
  6. On reload, tool_calls extracts ALL tool_call blocks including the fc_ ones
  7. OpenAI API rejects: "No tool output found for function call fc_xxx"

Root cause in AIMessage.content_blocks:

# Current code dedupes by ID only
content_tool_call_ids = {
    block.get("id")
    for block in self.content
    if isinstance(block, dict) and block.get("type") == "tool_call"
}
for tool_call in self.tool_calls:
    if (id_ := tool_call.get("id")) and id_ not in content_tool_call_ids:
        blocks.append(tool_call_block)  # fc_xxx != call_xxx, so BOTH added

Proposed fix - filter empty-args tool_calls (always streaming artifacts):

# In content_blocks property, after getting blocks from super()
blocks = [
    block for block in blocks
    if not (
        isinstance(block, dict) and
        block.get("type") == "tool_call" and
        not block.get("args")
    )
]

Error Message

""" Minimal reproduction: content_blocks includes duplicate tool_calls when using OpenAI Responses API streaming.

No API key needed - demonstrates the core issue. """

from langchain_core.messages import AIMessage, ToolMessage

def main(): # Simulates what OpenAI Responses API streaming produces: # - content: accumulates fc_ prefixed blocks (partial, empty args) # - tool_calls: final call_ prefixed blocks (full args)

simulated_content = [
    {"type": "text", "text": "Let me check the weather."},
    # fc_ blocks accumulated during streaming (partial, no args)
    {"type": "tool_call", "id": "fc_12345", "name": "get_weather", "args": {}},
    {"type": "tool_call", "id": "fc_67890", "name": "get_time", "args": {}},
]

# Final tool_calls with call_ prefix and full args
simulated_tool_calls = [
    {"id": "call_abc123", "name": "get_weather", "args": {"city": "Tokyo"}},
    {"id": "call_def456", "name": "get_time", "args": {"timezone": "JST"}},
]

msg = AIMessage(content=simulated_content, tool_calls=simulated_tool_calls)

print(f"content: {len(msg.content)} blocks")
print(f"content_blocks: {len(msg.content_blocks)} blocks")
print(f"tool_calls property: {len(msg.tool_calls)} entries")

print("\nTool calls in content_blocks:")
for block in msg.content_blocks:
    if isinstance(block, dict) and block.get("type") == "tool_call":
        print(f"  id={block.get('id')}, args_empty={not bool(block.get('args'))}")

# Serialize using content_blocks (recommended pattern)
serialized = {"type": msg.type, "content_blocks": list(msg.content_blocks)}

# Deserialize
reloaded = AIMessage(content_blocks=serialized["content_blocks"])

print(f"\nAfter reload - tool_calls: {len(reloaded.tool_calls)} entries")
for tc in reloaded.tool_calls:
    print(f"  id={tc.get('id')}, has_args={bool(tc.get('args'))}")

# The bug: fc_ blocks have no matching ToolMessage responses
tool_responses = [
    ToolMessage(content="Sunny", tool_call_id="call_abc123"),
    ToolMessage(content="14:30", tool_call_id="call_def456"),
]
response_ids = {tm.tool_call_id for tm in tool_responses}
missing = [tc for tc in reloaded.tool_calls if tc.get("id") not in response_ids]

print(f"\nMissing responses for: {[tc.get('id') for tc in missing]}")
print("API error: 'No tool output found for function call fc_12345'")

if name == "main": main()

Root Cause

Root cause in AIMessage.content_blocks:

Code Example

"""
Minimal reproduction: content_blocks includes duplicate tool_calls
when using OpenAI Responses API streaming.

No API key needed - demonstrates the core issue.
"""

from langchain_core.messages import AIMessage, ToolMessage


def main():
    # Simulates what OpenAI Responses API streaming produces:
    # - content: accumulates fc_ prefixed blocks (partial, empty args)
    # - tool_calls: final call_ prefixed blocks (full args)
    
    simulated_content = [
        {"type": "text", "text": "Let me check the weather."},
        # fc_ blocks accumulated during streaming (partial, no args)
        {"type": "tool_call", "id": "fc_12345", "name": "get_weather", "args": {}},
        {"type": "tool_call", "id": "fc_67890", "name": "get_time", "args": {}},
    ]
    
    # Final tool_calls with call_ prefix and full args
    simulated_tool_calls = [
        {"id": "call_abc123", "name": "get_weather", "args": {"city": "Tokyo"}},
        {"id": "call_def456", "name": "get_time", "args": {"timezone": "JST"}},
    ]
    
    msg = AIMessage(content=simulated_content, tool_calls=simulated_tool_calls)
    
    print(f"content: {len(msg.content)} blocks")
    print(f"content_blocks: {len(msg.content_blocks)} blocks")
    print(f"tool_calls property: {len(msg.tool_calls)} entries")
    
    print("\nTool calls in content_blocks:")
    for block in msg.content_blocks:
        if isinstance(block, dict) and block.get("type") == "tool_call":
            print(f"  id={block.get('id')}, args_empty={not bool(block.get('args'))}")
    
    # Serialize using content_blocks (recommended pattern)
    serialized = {"type": msg.type, "content_blocks": list(msg.content_blocks)}
    
    # Deserialize
    reloaded = AIMessage(content_blocks=serialized["content_blocks"])
    
    print(f"\nAfter reload - tool_calls: {len(reloaded.tool_calls)} entries")
    for tc in reloaded.tool_calls:
        print(f"  id={tc.get('id')}, has_args={bool(tc.get('args'))}")
    
    # The bug: fc_ blocks have no matching ToolMessage responses
    tool_responses = [
        ToolMessage(content="Sunny", tool_call_id="call_abc123"),
        ToolMessage(content="14:30", tool_call_id="call_def456"),
    ]
    response_ids = {tm.tool_call_id for tm in tool_responses}
    missing = [tc for tc in reloaded.tool_calls if tc.get("id") not in response_ids]
    
    print(f"\nMissing responses for: {[tc.get('id') for tc in missing]}")
    print("API error: 'No tool output found for function call fc_12345'")


if __name__ == "__main__":
    main()

---

content: 3 blocks
content_blocks: 5 blocks
tool_calls property: 2 entries

Tool calls in content_blocks:
  id=fc_12345, args_empty=True
  id=fc_67890, args_empty=True
  id=call_abc123, args_empty=False
  id=call_def456, args_empty=False

After reload - tool_calls: 4 entries
  id=fc_12345, has_args=False
  id=fc_67890, has_args=False
  id=call_abc123, has_args=True
  id=call_def456, has_args=True

Missing responses for: ['fc_12345', 'fc_67890']
API error: 'No tool output found for function call fc_12345'

---

# Current code dedupes by ID only
content_tool_call_ids = {
    block.get("id")
    for block in self.content
    if isinstance(block, dict) and block.get("type") == "tool_call"
}
for tool_call in self.tool_calls:
    if (id_ := tool_call.get("id")) and id_ not in content_tool_call_ids:
        blocks.append(tool_call_block)  # fc_xxx != call_xxx, so BOTH added

---

# In content_blocks property, after getting blocks from super()
blocks = [
    block for block in blocks
    if not (
        isinstance(block, dict) and
        block.get("type") == "tool_call" and
        not block.get("args")
    )
]

---

System Information
------------------
> OS:  Darwin
> OS Version:  Darwin Kernel Version 25.2.0
> Python Version:  3.13.11

Package Information
-------------------
> langchain_core: 1.3.1
> langchain: 1.2.15
> langchain_openai: 1.2.0
> langsmith: 0.7.35
> openai: 2.32.0
RAW_BUFFERClick to expand / collapse

Reproduction

"""
Minimal reproduction: content_blocks includes duplicate tool_calls
when using OpenAI Responses API streaming.

No API key needed - demonstrates the core issue.
"""

from langchain_core.messages import AIMessage, ToolMessage


def main():
    # Simulates what OpenAI Responses API streaming produces:
    # - content: accumulates fc_ prefixed blocks (partial, empty args)
    # - tool_calls: final call_ prefixed blocks (full args)
    
    simulated_content = [
        {"type": "text", "text": "Let me check the weather."},
        # fc_ blocks accumulated during streaming (partial, no args)
        {"type": "tool_call", "id": "fc_12345", "name": "get_weather", "args": {}},
        {"type": "tool_call", "id": "fc_67890", "name": "get_time", "args": {}},
    ]
    
    # Final tool_calls with call_ prefix and full args
    simulated_tool_calls = [
        {"id": "call_abc123", "name": "get_weather", "args": {"city": "Tokyo"}},
        {"id": "call_def456", "name": "get_time", "args": {"timezone": "JST"}},
    ]
    
    msg = AIMessage(content=simulated_content, tool_calls=simulated_tool_calls)
    
    print(f"content: {len(msg.content)} blocks")
    print(f"content_blocks: {len(msg.content_blocks)} blocks")
    print(f"tool_calls property: {len(msg.tool_calls)} entries")
    
    print("\nTool calls in content_blocks:")
    for block in msg.content_blocks:
        if isinstance(block, dict) and block.get("type") == "tool_call":
            print(f"  id={block.get('id')}, args_empty={not bool(block.get('args'))}")
    
    # Serialize using content_blocks (recommended pattern)
    serialized = {"type": msg.type, "content_blocks": list(msg.content_blocks)}
    
    # Deserialize
    reloaded = AIMessage(content_blocks=serialized["content_blocks"])
    
    print(f"\nAfter reload - tool_calls: {len(reloaded.tool_calls)} entries")
    for tc in reloaded.tool_calls:
        print(f"  id={tc.get('id')}, has_args={bool(tc.get('args'))}")
    
    # The bug: fc_ blocks have no matching ToolMessage responses
    tool_responses = [
        ToolMessage(content="Sunny", tool_call_id="call_abc123"),
        ToolMessage(content="14:30", tool_call_id="call_def456"),
    ]
    response_ids = {tm.tool_call_id for tm in tool_responses}
    missing = [tc for tc in reloaded.tool_calls if tc.get("id") not in response_ids]
    
    print(f"\nMissing responses for: {[tc.get('id') for tc in missing]}")
    print("API error: 'No tool output found for function call fc_12345'")


if __name__ == "__main__":
    main()

Output:

content: 3 blocks
content_blocks: 5 blocks
tool_calls property: 2 entries

Tool calls in content_blocks:
  id=fc_12345, args_empty=True
  id=fc_67890, args_empty=True
  id=call_abc123, args_empty=False
  id=call_def456, args_empty=False

After reload - tool_calls: 4 entries
  id=fc_12345, has_args=False
  id=fc_67890, has_args=False
  id=call_abc123, has_args=True
  id=call_def456, has_args=True

Missing responses for: ['fc_12345', 'fc_67890']
API error: 'No tool output found for function call fc_12345'

Description

When using ChatOpenAI(use_responses_api=True, streaming=True), the content_blocks property includes duplicate tool_calls - both partial fc_ prefixed blocks (with empty args) from streaming AND final call_ prefixed blocks (with full args).

What happens:

  1. OpenAI Responses API emits partial function calls during streaming with fc_ prefix IDs and empty args
  2. When streaming completes, tool_calls property contains final call_ prefix IDs with full args
  3. content_blocks property dedupes by ID only
  4. Since fc_xxx != call_xxx, both get included
  5. When serializing with content_blocks and later deserializing, both versions persist
  6. On reload, tool_calls extracts ALL tool_call blocks including the fc_ ones
  7. OpenAI API rejects: "No tool output found for function call fc_xxx"

Root cause in AIMessage.content_blocks:

# Current code dedupes by ID only
content_tool_call_ids = {
    block.get("id")
    for block in self.content
    if isinstance(block, dict) and block.get("type") == "tool_call"
}
for tool_call in self.tool_calls:
    if (id_ := tool_call.get("id")) and id_ not in content_tool_call_ids:
        blocks.append(tool_call_block)  # fc_xxx != call_xxx, so BOTH added

Proposed fix - filter empty-args tool_calls (always streaming artifacts):

# In content_blocks property, after getting blocks from super()
blocks = [
    block for block in blocks
    if not (
        isinstance(block, dict) and
        block.get("type") == "tool_call" and
        not block.get("args")
    )
]

System Info

System Information
------------------
> OS:  Darwin
> OS Version:  Darwin Kernel Version 25.2.0
> Python Version:  3.13.11

Package Information
-------------------
> langchain_core: 1.3.1
> langchain: 1.2.15
> langchain_openai: 1.2.0
> langsmith: 0.7.35
> openai: 2.32.0

extent analysis

TL;DR

Filter out empty-args tool_calls from content_blocks to prevent duplicate tool_calls.

Guidance

  • Identify and remove fc_ prefixed blocks with empty args from content_blocks to prevent duplication.
  • Modify the content_blocks property to filter out tool_calls with empty args, as proposed in the issue.
  • Verify that the tool_calls property only contains the final call_ prefixed blocks with full args after deserialization.
  • Test the fix by running the provided reproduction code and checking the output for duplicate tool_calls.

Example

# In content_blocks property, after getting blocks from super()
blocks = [
    block for block in blocks
    if not (
        isinstance(block, dict) and
        block.get("type") == "tool_call" and
        not block.get("args")
    )
]

Notes

This fix assumes that empty-args tool_calls are always streaming artifacts and can be safely removed. If this assumption is not valid, further modifications may be needed.

Recommendation

Apply the proposed workaround by filtering out empty-args tool_calls from content_blocks, as it directly addresses the identified root cause and prevents duplicate tool_calls.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING