agentscope-ai / agentscope-ai/agentscope
[Bug]: OpenAIChatFormatter: orphaned role="tool" message when self-paired Msg has empty content_blocks/tool_calls
- Langage dominant
- Python
- Étoiles
- 31.5k
- Forks
- 3.5k
- Merge moyen
- 1 j 23 h
- PR mergées (30 j)
- 95
Description
### Prerequisites
- [x] I have searched the existing [issues](https://github.com/agentscope-ai/agentscope/issues) and [discussions](https://github.com/agentscope-ai/agentscope/discussions), and this is not a duplicate.
- [x] This is a bug, not a usage question. (For questions, please use [Discussions](https://github.com/agentscope-ai/agentscope/discussions/new?category=general) instead.)
### Background / Description
OpenAIChatFormatter: role="tool" message orphaned when self-paired Msg has empty content_blocks and tool_calls
### Error Messages
```shell
OpenAIChatFormatter: role="tool" message orphaned when self-paired Msg has empty content_blocks and tool_calls
Environment
agentscope version: 2.0.4
Provider: DeepSeek (OpenAI-compatible API)
Python: 3.11
Bug Description
When a single Msg contains both ToolCallBlock and ToolResultBlock (self-paired format), the OpenAIChatFormatter can generate an orphaned role="tool" message that has no preceding role="assistant" message with tool_calls, causing the LLM API to reject the request with:
Messages with role 'tool' must be a response to a preceding message with 'tool_calls'
Root Cause
In _openai_formatter.py (lines 290-314), when processing a ToolResultBlock, the formatter first flushes any accumulated content_blocks and tool_calls:
elif isinstance(block, ToolResultBlock):
if content_blocks or tool_calls:
msg_openai_flush = {
"role": msg.role,
"name": msg.name,
"content": content_blocks or None,
}
if tool_calls:
msg_openai_flush["tool_calls"] = tool_calls
messages.append(msg_openai_flush)
content_blocks = []
tool_calls = []
# ... then appends the role="tool" message
messages.append({
"role": "tool",
"tool_call_id": block.id,
"content": textual_output,
"name": block.name,
})
And at the end of the message loop (lines 353-361), the formatter conditionally appends the remaining message:
msg_openai = {
"role": msg.role,
"name": msg.name,
"content": content_blocks or None,
}
if tool_calls:
msg_openai["tool_calls"] = tool_calls
if msg_openai["content"] or msg_openai.get("tool_calls"):
messages.append(msg_openai)
The problem: When a self-paired Msg has this structure:
content_blocks = [ToolCallBlock(id="a"), ToolCallBlock(id="b"), ToolResultBlock(id="a"), ToolResultBlock(id="b")]
The ToolCallBlock processor accumulates tool_calls = [a, b]
The ToolResultBlock processor flushes this as a role="assistant" message with tool_calls=[a, b] and clears both content_blocks and tool_calls
Then it appends role="tool" messages for each result
At the end of the loop, content_blocks is empty and tool_calls is empty, so the final msg_openai is skipped entirely.
The output sequence can look like:
{"role": "assistant", "tool_calls": [...]} ← flushed from the ToolResultBlock handler
{"role": "tool", "tool_call_id": "a", ...}
{"role": "tool", "tool_call_id": "b", ...}
This is correct in most cases. However, the bug manifests when the content_blocks or tool_calls lists happen to be empty at the moment ToolResultBlock is encountered. This can happen when:
The Msg contains only ToolResultBlocks without preceding ToolCallBlocks in the same message (a malformed self-paired message from a previous round)
A pipeline-timing issue where content_blocks was already drained by a preceding HintBlock or similar block type
In these cases, the flush at line 291 (if content_blocks or tool_calls:) does NOT execute, so no role="assistant" message with tool_calls is emitted before the role="tool" messages. The result is orphaned role="tool" messages that cause a 400 error from the API.
Reproduction
Occurs intermittently during multi-turn conversations where the agent calls multiple tools in a single response. Not deterministic — depends on the exact block ordering within the Msg.
Suggested Fix
In the ToolResultBlock handler, ensure a role="assistant" message with tool_calls is always emitted before the role="tool" messages, even when content_blocks and tool_calls are empty. The fix could:
Option A: Before appending the role="tool" message, always emit a placeholder role="assistant" message with tool_calls if one hasn't been emitted yet in this message:
# Before appending tool results, ensure there's a preceding assistant message
# with tool_calls, even if content_blocks/tool_calls were already flushed
if not has_assistant_tool_message:
messages.append({
"role": msg.role or "assistant",
"name": msg.name,
"content": None,
"tool_calls": [...], # reconstructed from previous ToolCallBlocks
})
has_assistant_tool_message = True
Option B: Track whether the flush happened and reconstruct tool_calls from a per-message list if needed.
Workaround
Users can avoid this by ensuring their agent prompts encourage single-tool-call-per-message patterns, but this is not a reliable fix — the LLM's output format is non-deterministic.
```
### Steps to Reproduce
Reproduction
Occurs intermittently during multi-turn conversations where the agent calls multiple tools in a single response. Not deterministic — depends on the exact block ordering within the Msg.
### Environment
agentscope version: 2.0.4
Provider: DeepSeek (OpenAI-compatible API)
Python: 3.11
Guide de contribution
Ouvrir le guide de contribution
Évaluation
Cette issue n'a pas encore été évaluée.