agentscope-ai / agentscope-ai/agentscope
[Bug]: OpenAIChatFormatter: orphaned role="tool" message when self-paired Msg has empty content_blocks/tool_calls
- 主要語言
- Python
- 星號
- 31.6k
- 分支
- 3.5k
- 平均合併
- 1 天 16 小時
- 30 天內合併 PR
- 103
描述
### 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
貢獻指南
研究方向
Inspect `_openai_formatter.py`, especially the `ToolResultBlock` handler around lines 290-314 and the final `msg_openai` flush around 353-361, since those are where assistant/tool messages are emitted or skipped. Trace a self-paired `Msg` containing `ToolCallBlock`/`ToolResultBlock` sequences and verify whether `msg_openai` is built before each `role: "tool"` append. Run the reported multi-turn multi-tool scenario through the formatter and check the output order; done means no orphaned `tool` messages and every tool result is preceded by an assistant message with `tool_calls`. No test file is named in the report, so use this formatting path as the first validation target.
由索引模型根據 Issue 內容生成。
評估
- 技術堆疊
- python
- 領域
- api
- Issue 類型
- 缺陷
- 難度
- 2/5
- 預估耗時
- 1-3 小時
- 活躍度
- 冷清
- 描述清晰度
- 描述清楚
- 新手友好度
- 82/100