#2675·agentscope

[Bug]: OpenAIChatFormatter: multimodal tool results promoted mid-batch break parallel tool_calls (400 on DeepSeek & other strict OpenAI-compatible providers)

Author: chenqpCreated Sep 17, 2026Updated Sep 17, 2026

Prerequisites

  • I have searched the existing issues and discussions, and this is not a duplicate.
  • This is a bug, not a usage question. (For questions, please use Discussions instead.)

Background / Description

Summary

When an agent issues parallel tool calls (multiple ToolCallBlocks in one Msg) and the tool results contain multimodal data (DataBlock, e.g. images read via a read_file tool), OpenAIChatFormatter inserts the promoted multimodal message (role=user, name=system-reminder) between the sibling tool messages of the same tool_calls batch. Providers that strictly validate the protocol reject the whole request with HTTP 400:

An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. (insufficient tool messages following
tool_calls message)

Single tool calls are unaffected (the promo naturally lands after the only tool message), so the symptom appears as intermittent 400s on an otherwise healthy session — every "parallel tool call + non-final tool returns an image" turn fails.

Actual output — note the user promo between tool call_A and tool call_B: user - assistant tool_calls: ['call_A', 'call_B'] tool call_A user system-reminder [image promo] tool call_B user system-reminder [image promo]

Sending this history to api.deepseek.com returns: HTTP 400 {"error":{"message":"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)", "type":"invalid_request_error","code":"invalid_request_error"}}

HTTP 400 {"error":{"message":"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)", "type":"invalid_request_error","code":"invalid_request_error"}}

Minimal raw-HTTP proof that ordering is the only factor (same payload, same images, same model):

assistant(2 tool_calls) → tool(A) → tool(B) → user(image) → user(image) → 200 OK assistant(2 tool_calls) → tool(A) → user(image) → tool(B) → user(image) → 400 with the error above

Root cause agentscope/formatter/_openai_formatter.py, the ToolResultBlock branch of OpenAIChatFormatter.format (~lines 335–382): each tool result is emitted as its role=tool message, and its multimodal payload is immediately appended as a separate role=user system-reminder message — with no awareness of whether the surrounding tool batch (the preceding assistant tool_calls) still has outstanding responses.

Suggested fix Buffer the promoted multimodal messages while the tool batch is open, and flush them right after the last tool message of that batch, e.g.:

when appending a role=tool message, hold its promo in a per-batch buffer; after appending a tool message, if all tool_call_ids of the preceding assistant message are now answered, flush the buffered promos. Since a well-formed stream can never contain a user message inside an open tool batch, deferring promos to the batch boundary is semantically safe and a no-op for non-multimodal / single-tool flows.

Workaround we currently use Wrap OpenAIChatFormatter.format with a post-pass that defers any user message found while a tool_calls batch still has unanswered ids (pure reordering, content untouched). Happy to contribute the fix upstream if the approach sounds right.

Error Messages

bash
HTTP 400 {"error":{"message":"An assistant message with 'tool_calls' must
be followed by tool messages responding to each 'tool_call_id'.
(insufficient tool messages following tool_calls message)",
"type":"invalid_request_error","code":"invalid_request_error"}}

Steps to Reproduce

Reproduction

python
import asyncio, base64
from agentscope.formatter import OpenAIChatFormatter
from agentscope.message import (DataBlock, Msg, TextBlock,
                                ToolCallBlock, ToolResultBlock)

IMG = base64.b64encode(b"fake-png").decode()

def image_result(call_id):
    return ToolResultBlock(id=call_id, name="read_file", output=[
        TextBlock(text="[image file]"),
        DataBlock(id=f"data-{call_id}",
                  source={"type": "base64", "data": IMG,
                          "media_type": "image/png"}),
    ])

agent_msg = Msg(name="assistant", role="assistant", content=[
    ToolCallBlock(id="call_A", name="read_file", input='{"path": "a.png"}'),
    ToolCallBlock(id="call_B", name="read_file", input='{"path": "b.png"}'),
    image_result("call_A"),
    image_result("call_B"),
])

async def main():
    messages = await OpenAIChatFormatter().format([
        Msg(name="user", role="user", content=[TextBlock(text="read both")]),
        agent_msg,
    ])
    for m in messages:
        if m["role"] == "user" and m.get("name") == "system-reminder":
            print(m["role"], m["name"], "[image promo]")
        elif m["role"] == "tool":
            print("tool", m["tool_call_id"])
        elif m["role"] == "assistant" and m.get("tool_calls"):
            print("assistant tool_calls:", [tc["id"] for tc in m["tool_calls"]])
        else:
            print(m["role"], "-")

asyncio.run(main())


### Environment

## Environment

- agentscope 2.0.8 (also affects the whole `>=2.0.8,<2.1` range we tested)
- Provider: DeepSeek (`https://api.deepseek.com/chat/completions`) via `OpenAIChatModel` with an OpenAI-compatible credential
- Python 3.13 / 3.14, Windows

Source: agentscope-ai/agentscope