agentscope-ai / agentscope-ai/agentscope

[Bug]: OpenAIChatFormatter: orphaned role="tool" message when self-paired Msg has empty content_blocks/tool_calls

未關閉 適合新手
#2,067 4 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視
state: needs clarification
主要語言
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

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。