agentscope-ai / agentscope-ai/agentscope

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

Ouverte
#2,067 4 commentaires 0 réactions 0 personnes assignées Voir sur GitHub
state: needs clarification
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.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.